Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ const config = {
// `lib/helpers/redactUrl.js` (`redactPathSecrets` for PATH segments e.g.
// `/api/auth/reset/:token`; `redactUrl` for query params e.g.
// `?inviteToken=…`). Values are UNIONED with the built-in defaults there
// (['reset', 'verify', 'verify-email'] / ['inviteToken']), never
// replacing them.
// (['reset', 'verify', 'verify-email'] / ['inviteToken', 'code', 'state']),
// never replacing them.
//
// These two keys are intentionally OMITTED from this file: `deepMerge`
// (config/index.js) replaces arrays wholesale rather than merging them,
Expand Down
11 changes: 7 additions & 4 deletions lib/helpers/redactUrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import config from '../../config/index.js';
* silently disabled (an absent key or an explicit empty array in config both
* degrade to exactly these defaults, never to nothing).
*/
const DEFAULT_SENSITIVE_QUERY_KEYS = ['inviteToken'];
const DEFAULT_SENSITIVE_QUERY_KEYS = ['inviteToken', 'code', 'state'];
const DEFAULT_SENSITIVE_PATH_MARKERS = ['reset', 'verify', 'verify-email'];

/**
Expand Down Expand Up @@ -42,9 +42,12 @@ const sanitizeConfigList = (value, label) => {
* Sensitive query-string parameters that must never reach the request log.
*
* `inviteToken` rides the query string of `POST /api/auth/signup?inviteToken=…`
* (the Vue client puts it there, not in the body). The morgan log pattern logs
* `:url`, so without redaction a live single-use invite token lands in prod logs
* (and any log shipper / aggregator downstream). Redact it to `REDACTED`.
* (the Vue client puts it there, not in the body). `code` and `state` ride the
* query string of `GET /api/auth/:strategy/callback?code=…&state=…` (the OAuth
* provider redirects there with the one-time authorization code). The morgan
* log pattern logs `:url`, so without redaction a live single-use secret lands
* in prod logs (and any log shipper / aggregator downstream). Redact them to
* `REDACTED`.
*
* Union of `DEFAULT_SENSITIVE_QUERY_KEYS` and `config.log.sensitiveQueryKeys`:
* a module/downstream project extends the set purely additively from its own
Expand Down
21 changes: 17 additions & 4 deletions lib/helpers/tests/redactUrl.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ describe('redactUrl', () => {
test('exports inviteToken as a sensitive key', () => {
expect(SENSITIVE_QUERY_KEYS).toContain('inviteToken');
});

test('redacts an OAuth callback code and state, preserving the path', () => {
expect(redactUrl('/api/auth/google/callback?code=SECRET&state=xyz')).toBe('/api/auth/google/callback?code=REDACTED&state=REDACTED');
});

test('redacts an OAuth callback code among other query params, leaving the rest intact', () => {
expect(redactUrl('/api/auth/google/callback?state=xyz&scope=email&code=SECRET')).toBe('/api/auth/google/callback?state=REDACTED&scope=email&code=REDACTED');
});

test('exports code and state as sensitive keys', () => {
expect(SENSITIVE_QUERY_KEYS).toContain('code');
expect(SENSITIVE_QUERY_KEYS).toContain('state');
});
});

describe('redactPathSecrets', () => {
Expand Down Expand Up @@ -168,23 +181,23 @@ describe('redactUrl config sourcing (#3953)', () => {
log: { sensitivePathMarkers: [], sensitiveQueryKeys: [] },
});
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']);
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
});

test('falls back to the built-in defaults when config.log is missing', async () => {
const mod = await loadWithConfig({});
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']);
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
});

test('falls back to the built-in defaults when config itself is undefined', async () => {
const mod = await loadWithConfig(undefined);
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']);
});

/**
Expand Down Expand Up @@ -214,7 +227,7 @@ describe('redactUrl config sourcing (#3953)', () => {
const mod = await loadWithConfig({
log: { sensitiveQueryKeys: 'oops' },
});
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken', 'code', 'state']);
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('log.sensitiveQueryKeys'));
} finally {
Expand Down
Loading