diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index b05d7fccf..c5516cbb4 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -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, diff --git a/lib/helpers/redactUrl.js b/lib/helpers/redactUrl.js index d92dfd537..ff29d793e 100644 --- a/lib/helpers/redactUrl.js +++ b/lib/helpers/redactUrl.js @@ -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']; /** @@ -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 diff --git a/lib/helpers/tests/redactUrl.unit.tests.js b/lib/helpers/tests/redactUrl.unit.tests.js index 434508866..85ecbc66c 100644 --- a/lib/helpers/tests/redactUrl.unit.tests.js +++ b/lib/helpers/tests/redactUrl.unit.tests.js @@ -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', () => { @@ -168,7 +181,7 @@ 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'); }); @@ -176,7 +189,7 @@ describe('redactUrl config sourcing (#3953)', () => { 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'); }); @@ -184,7 +197,7 @@ describe('redactUrl config sourcing (#3953)', () => { 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']); }); /** @@ -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 {