feat(apps): ad-hoc redaction for apps logs (#40096)
Co-authored-by: Diego Sampaio <chinello@gmail.com>pull/39944/merge
parent
f4dfb8ddc2
commit
95a82f72dd
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@rocket.chat/server-fetch': minor |
||||
--- |
||||
|
||||
Introduces redaction of potentially sensitive data when logging request URLs |
||||
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@rocket.chat/meteor': minor |
||||
--- |
||||
|
||||
Introduces redaction of potentially sensitive data in logs related to apps-engine |
||||
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@rocket.chat/tools': minor |
||||
--- |
||||
|
||||
Adds new function for censoring URL components in logs |
||||
@ -0,0 +1,42 @@ |
||||
import fastRedact from 'fast-redact'; |
||||
|
||||
const requestFields = [ |
||||
'headers.Cookie', |
||||
'headers.cookie', |
||||
'headers["x-auth-token"]', |
||||
'headers["X-Auth-Token"]', |
||||
'headers.auth', |
||||
'headers.Auth', |
||||
'headers.authorization', |
||||
'headers.Authorization', |
||||
'headers.access_token', |
||||
'content.password', |
||||
'content.pass', |
||||
'data.password', |
||||
'data.pass', |
||||
]; |
||||
|
||||
const entityFields = ['password', 'pass', 'customFields.*', '_unmappedProperties_']; |
||||
|
||||
const roomFields = ['customFields.*', '_unmappedProperties_', ...entityFields.map((field) => `creator.${field}`)]; |
||||
|
||||
export const redactionFieldPaths = [ |
||||
// Incoming requests to the Apps API endpoints
|
||||
...requestFields, |
||||
...entityFields.map((field) => `user.${field}`), |
||||
'query.access_token', |
||||
'query.query', // The deprecated `query` search param
|
||||
// Outgoing requests from the Apps to the outter webs
|
||||
...requestFields.map((field) => `request.${field}`), |
||||
`request.query`, // `query` here is a string, so we have to redact it all
|
||||
// Slashcommands
|
||||
...roomFields.map((field) => `params[0].room.${field}`), |
||||
...entityFields.map((field) => `params[0].sender.${field}`), |
||||
]; |
||||
|
||||
export const redact = fastRedact({ |
||||
paths: redactionFieldPaths, |
||||
censor: '[Redacted]', |
||||
serialize: false, |
||||
strict: false, |
||||
}); |
||||
@ -0,0 +1,53 @@ |
||||
import { censorUrl } from './censorUrl'; |
||||
|
||||
describe('censorUrl', () => { |
||||
it('returns the original value when URL parsing fails', () => { |
||||
const input = 'not-a-url'; |
||||
|
||||
expect(censorUrl(input)).toBe(input); |
||||
}); |
||||
|
||||
it('returns relative URLs unchanged when no base is provided', () => { |
||||
const input = '/path/to/resource?query=secret&access_token=token'; |
||||
|
||||
expect(censorUrl(input)).toBe(input); |
||||
}); |
||||
|
||||
it('does not change URLs without sensitive parts', () => { |
||||
expect(censorUrl('https://example.com/path?foo=bar')).toBe('https://example.com/path?foo=bar'); |
||||
}); |
||||
|
||||
it('redacts username and password from auth section', () => { |
||||
expect(censorUrl('https://user:password@example.com/path')).toBe('https://*Redacted*:*Redacted*@example.com/path'); |
||||
}); |
||||
|
||||
it('redacts only username when password is not present', () => { |
||||
expect(censorUrl('https://user@example.com/path')).toBe('https://*Redacted*@example.com/path'); |
||||
}); |
||||
|
||||
it('redacts query and access_token search params', () => { |
||||
expect(censorUrl('https://example.com/path?query=secret&access_token=token&foo=bar')).toBe( |
||||
'https://example.com/path?query=*Redacted*&access_token=*Redacted*&foo=bar', |
||||
); |
||||
}); |
||||
|
||||
it('redacts access_token even when query is absent', () => { |
||||
expect(censorUrl('https://example.com/path?access_token=token&foo=bar')).toBe( |
||||
'https://example.com/path?access_token=*Redacted*&foo=bar', |
||||
); |
||||
}); |
||||
|
||||
it('accepts URL objects as input', () => { |
||||
expect(censorUrl(new URL('https://user:password@example.com/path?query=secret'))).toBe( |
||||
'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*', |
||||
); |
||||
}); |
||||
|
||||
it('does not modify the original URL object', () => { |
||||
const input = new URL('https://user:password@example.com/path?query=secret&access_token=token'); |
||||
const originalValue = input.toString(); |
||||
|
||||
expect(censorUrl(input)).toBe('https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*'); |
||||
expect(input.toString()).toBe(originalValue); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,45 @@ |
||||
/** |
||||
* Redacts sensitive information from a URL string. |
||||
* |
||||
* This function parses a URL and replaces potentially sensitive data with placeholder text. |
||||
* It redacts the following URL components: |
||||
* - Username in the authentication section |
||||
* - Password in the authentication section |
||||
* - Query parameters named: 'query', 'access_token' |
||||
* |
||||
* Note: We use `*Redacted*` instead of `[Redacted]` for legibility, as `[` and `]` would be encoded by toString() |
||||
* |
||||
* @param url - The URL string to be censored |
||||
* @returns The URL string with sensitive information redacted, or the original URL if parsing fails |
||||
* |
||||
* @example |
||||
* ```ts
|
||||
* censorUrl('https://user:password@example.com/path?query=secret&access_token=token'); |
||||
* // Returns: 'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*'
|
||||
* ``` |
||||
*/ |
||||
export function censorUrl(url: string | URL): string { |
||||
try { |
||||
const parsedUrl = new URL(url); |
||||
|
||||
if (parsedUrl.username) { |
||||
parsedUrl.username = '*Redacted*'; |
||||
} |
||||
|
||||
if (parsedUrl.password) { |
||||
parsedUrl.password = '*Redacted*'; |
||||
} |
||||
|
||||
if (parsedUrl.searchParams.has('query')) { |
||||
parsedUrl.searchParams.set('query', '*Redacted*'); |
||||
} |
||||
|
||||
if (parsedUrl.searchParams.has('access_token')) { |
||||
parsedUrl.searchParams.set('access_token', '*Redacted*'); |
||||
} |
||||
|
||||
return parsedUrl.toString(); |
||||
} catch { |
||||
return url.toString(); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue