Skip to content

Commit 8c3b208

Browse files
refactor(log): source sensitive redaction lists from config as pure extensions (#3961)
* refactor(log): source sensitive redaction markers from config lib/ must stay module-agnostic — SENSITIVE_PATH_MARKERS and SENSITIVE_QUERY_KEYS in lib/helpers/redactUrl.js hardcoded auth/invitations route vocabulary owned by feature modules (#3935). Move both lists to config.log.sensitivePathMarkers / config.log.sensitiveQueryKeys (current values as defaults in config/defaults/development.config.js) so a module extends redaction coverage from its own config without editing shared lib/. redactUrl.js reads the lists from config once at module load, falling back to the built-in literals when config or the key is absent — redaction degrades safely instead of silently redacting nothing in an edge context. Exports/API unchanged (existing consumers: lib/middlewares/analytics.js, lib/services/express.js). Closes #3953 Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * fix(log): union config-provided redaction lists with built-in defaults deepMerge replaces arrays (no union), so a module-level config.log.sensitivePathMarkers/sensitiveQueryKeys extension clobbered the global default instead of extending it, and a `??` fallback let an explicit empty array silently disable redaction for that vector. Compute the effective lists as a Set-deduped union of the built-in DEFAULT_SENSITIVE_* base with config.log.*, so config is purely additive from any layer and redaction can never be disabled via config. development.config.js's entries become empty arrays (the extension point), killing the literal duplication with redactUrl.js. Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * fix(log): make redaction config extension layer-proof and shape-safe Phase-0 iteration-2 review findings on #3953: - Remove log.sensitivePathMarkers/sensitiveQueryKeys from config/defaults/development.config.js. deepMerge (config/index.js) replaces arrays wholesale, so declaring these as [] at Layer 2 (global defaults) silently clobbered any Layer 1 (module config) extension of the same key. Omitting the key lets a module value pass through untouched; a comment documents the extension point. - Guard redactUrl.js against non-array config values via a sanitizeConfigList helper: an object/number would throw at module-eval (boot crash), a string would spread char-by-char (silent over-redaction). Malformed values now fall back to [] (defaults still apply) and log a console.warn identifying the offending config path. - Add malformed-value tests (object + string) and a lockstep guard test pinning that development.config.js never re-declares either key. Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * test(log): restore console.warn spy in finally to prevent mock leakage Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup
1 parent dffc577 commit 8c3b208

3 files changed

Lines changed: 202 additions & 3 deletions

File tree

config/defaults/development.config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ const config = {
8181
maxFiles: 2,
8282
json: false,
8383
},
84+
// Redaction extension points (optional — do NOT declare either key here,
85+
// not even as `[]`): a module or project config may set
86+
// `log.sensitivePathMarkers` / `log.sensitiveQueryKeys` (array of
87+
// strings) to extend the built-in redaction lists consumed by
88+
// `lib/helpers/redactUrl.js` (`redactPathSecrets` for PATH segments e.g.
89+
// `/api/auth/reset/:token`; `redactUrl` for query params e.g.
90+
// `?inviteToken=…`). Values are UNIONED with the built-in defaults there
91+
// (['reset', 'verify', 'verify-email'] / ['inviteToken']), never
92+
// replacing them.
93+
//
94+
// These two keys are intentionally OMITTED from this file: `deepMerge`
95+
// (config/index.js) replaces arrays wholesale rather than merging them,
96+
// so an explicit value here (Layer 2: global defaults) would silently
97+
// clobber a module's own extension of the same key (Layer 1: module
98+
// config) instead of unioning with it. An omitted key lets a Layer-1
99+
// value pass through untouched to redactUrl.js.
84100
},
85101
csrf: {
86102
csrf: false,

lib/helpers/redactUrl.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,59 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import config from '../../config/index.js';
5+
6+
/**
7+
* Built-in, authoritative base lists. `lib/` must stay module-agnostic, so a
8+
* module/downstream project never edits this shared helper to add its own
9+
* token-bearing routes — it extends `config.log.sensitiveQueryKeys` /
10+
* `config.log.sensitivePathMarkers` (see below) instead. These built-ins
11+
* ALWAYS apply, unioned with whatever config contributes: config can only
12+
* ADD markers/keys, never remove or replace these, so redaction can never be
13+
* silently disabled (an absent key or an explicit empty array in config both
14+
* degrade to exactly these defaults, never to nothing).
15+
*/
16+
const DEFAULT_SENSITIVE_QUERY_KEYS = ['inviteToken'];
17+
const DEFAULT_SENSITIVE_PATH_MARKERS = ['reset', 'verify', 'verify-email'];
18+
19+
/**
20+
* @desc Sanitize a config-provided redaction list so it is always an array
21+
* before it is spread into the union below. A config typo (an object, a
22+
* number, a bare string, …) must never throw at module-eval time — that
23+
* would crash the whole app on boot — and must never silently degrade into
24+
* a per-character array either (spreading a string splits it into individual
25+
* characters, which is not the extension list a config author intended and
26+
* would slip straight past an `Array.isArray`-less spread). Falls back to
27+
* an empty array, which is a no-op for the union below (identical to an
28+
* absent/missing key — the built-in defaults still apply), and logs a
29+
* warning identifying the offending config path so the typo gets noticed.
30+
* @param {*} value - the raw `config.log.<key>` value
31+
* @param {String} label - dotted config path for the warning message, e.g. 'log.sensitivePathMarkers'
32+
* @returns {Array} `value` unchanged when it is already an array, otherwise `[]`
33+
*/
34+
const sanitizeConfigList = (value, label) => {
35+
if (Array.isArray(value)) return value;
36+
if (value == null) return [];
37+
console.warn(`[redactUrl] config.${label} ignored: expected an array, got ${typeof value}`);
38+
return [];
39+
};
40+
141
/**
242
* Sensitive query-string parameters that must never reach the request log.
343
*
444
* `inviteToken` rides the query string of `POST /api/auth/signup?inviteToken=…`
545
* (the Vue client puts it there, not in the body). The morgan log pattern logs
646
* `:url`, so without redaction a live single-use invite token lands in prod logs
747
* (and any log shipper / aggregator downstream). Redact it to `REDACTED`.
48+
*
49+
* Union of `DEFAULT_SENSITIVE_QUERY_KEYS` and `config.log.sensitiveQueryKeys`:
50+
* a module/downstream project extends the set purely additively from its own
51+
* config, without editing this shared helper. Deduplicated via `Set`. Missing
52+
* config, a missing key, an explicit empty array, or a malformed (non-array)
53+
* value all resolve to exactly the built-in defaults — config can never
54+
* shrink or clobber this list.
855
*/
9-
const SENSITIVE_QUERY_KEYS = ['inviteToken'];
56+
const SENSITIVE_QUERY_KEYS = [...new Set([...DEFAULT_SENSITIVE_QUERY_KEYS, ...sanitizeConfigList(config?.log?.sensitiveQueryKeys, 'log.sensitiveQueryKeys')])];
1057

1158
/**
1259
* Path segments that are immediately followed by a single-use secret carried as
@@ -15,8 +62,15 @@ const SENSITIVE_QUERY_KEYS = ['inviteToken'];
1562
* `GET /api/*(auth/)?invitations/verify/:token` embed a still-valid token
1663
* directly in the path, so the segment right after one of these markers must be
1764
* redacted before the URL reaches any log/analytics store.
65+
*
66+
* Union of `DEFAULT_SENSITIVE_PATH_MARKERS` and `config.log.sensitivePathMarkers`:
67+
* a module/downstream project extends the set purely additively from its own
68+
* config, without editing this shared helper. Deduplicated via `Set`. Missing
69+
* config, a missing key, an explicit empty array, or a malformed (non-array)
70+
* value all resolve to exactly the built-in defaults — config can never
71+
* shrink or clobber this list.
1872
*/
19-
const SENSITIVE_PATH_MARKERS = ['reset', 'verify', 'verify-email'];
73+
const SENSITIVE_PATH_MARKERS = [...new Set([...DEFAULT_SENSITIVE_PATH_MARKERS, ...sanitizeConfigList(config?.log?.sensitivePathMarkers, 'log.sensitivePathMarkers')])];
2074

2175
/**
2276
* @desc Redact single-use secrets embedded as PATH parameters. Any segment that

lib/helpers/tests/redactUrl.unit.tests.js

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, test, expect } from '@jest/globals';
1+
import { jest, describe, test, expect } from '@jest/globals';
22
import redactUrl, { SENSITIVE_QUERY_KEYS, SENSITIVE_PATH_MARKERS, redactPathSecrets } from '../redactUrl.js';
3+
import developmentConfig from '../../../config/defaults/development.config.js';
34

45
describe('redactUrl', () => {
56
test('redacts an inviteToken value, preserving the path', () => {
@@ -113,3 +114,131 @@ describe('redactPathSecrets', () => {
113114
expect(SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email']));
114115
});
115116
});
117+
118+
/**
119+
* `SENSITIVE_QUERY_KEYS` / `SENSITIVE_PATH_MARKERS` are read from
120+
* `config.log.*` once, at module-evaluation time (#3953), and UNIONED with
121+
* the built-in `DEFAULT_SENSITIVE_*` lists — config is extension-only, it can
122+
* never shrink or clobber the built-ins (deepMerge replaces arrays wholesale,
123+
* so a naive fallback would let a module-level config array silently clobber
124+
* the global default instead of extending it; union sidesteps that entirely).
125+
* These tests mock `config/index.js` and re-import the module fresh (via
126+
* `jest.resetModules()` + dynamic `import()` — see
127+
* `posthog-context.middleware.unit.tests.js` for the same pattern) to
128+
* exercise the union, the empty-array no-op, and the absent-config fallback,
129+
* without disturbing the static-import tests above (which exercise the real
130+
* config's current defaults, unchanged).
131+
*/
132+
describe('redactUrl config sourcing (#3953)', () => {
133+
/**
134+
* Load a fresh instance of the module under a given mocked config.
135+
* @param {Object|undefined} mockConfig - the value `config/index.js` default-exports
136+
* @returns {Promise<Object>} the freshly-loaded module exports
137+
*/
138+
const loadWithConfig = async (mockConfig) => {
139+
jest.resetModules();
140+
jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig }));
141+
return import('../redactUrl.js');
142+
};
143+
144+
test('unions a module-extended path marker from config with the built-in defaults', async () => {
145+
const mod = await loadWithConfig({
146+
log: { sensitivePathMarkers: ['unsubscribe'] },
147+
});
148+
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(expect.arrayContaining(['reset', 'verify', 'verify-email', 'unsubscribe']));
149+
// built-in marker still redacts — extension never clobbers it
150+
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
151+
// config-added marker redacts too
152+
expect(mod.redactPathSecrets('/api/newsletter/unsubscribe/SECRETTOKEN')).toBe('/api/newsletter/unsubscribe/REDACTED');
153+
});
154+
155+
test('unions a module-extended query key from config with the built-in defaults', async () => {
156+
const mod = await loadWithConfig({
157+
log: { sensitiveQueryKeys: ['magicToken'] },
158+
});
159+
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(expect.arrayContaining(['inviteToken', 'magicToken']));
160+
// built-in key still redacts — extension never clobbers it
161+
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
162+
// config-added key redacts too
163+
expect(mod.default('/x?magicToken=secret')).toBe('/x?magicToken=REDACTED');
164+
});
165+
166+
test('an explicit empty array in config is a no-op — built-in defaults still apply', async () => {
167+
const mod = await loadWithConfig({
168+
log: { sensitivePathMarkers: [], sensitiveQueryKeys: [] },
169+
});
170+
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
171+
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
172+
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
173+
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
174+
});
175+
176+
test('falls back to the built-in defaults when config.log is missing', async () => {
177+
const mod = await loadWithConfig({});
178+
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
179+
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
180+
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
181+
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
182+
});
183+
184+
test('falls back to the built-in defaults when config itself is undefined', async () => {
185+
const mod = await loadWithConfig(undefined);
186+
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
187+
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
188+
});
189+
190+
/**
191+
* A shape mismatch on the config value (e.g. a typo'd object instead of an
192+
* array) must degrade the same way an absent/empty value does — never throw
193+
* at module-eval time (`[...{}]` is not iterable, which would crash the
194+
* whole app on boot) and never silently spread a string char-by-char
195+
* (`[...'oops']` → `['o','o','p','s']`, which would over-redact app-wide).
196+
*/
197+
test('an object value for sensitivePathMarkers is ignored — defaults still apply, no throw, warns', async () => {
198+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
199+
try {
200+
const mod = await loadWithConfig({
201+
log: { sensitivePathMarkers: { notAnArray: true } },
202+
});
203+
expect(mod.SENSITIVE_PATH_MARKERS).toEqual(['reset', 'verify', 'verify-email']);
204+
expect(mod.redactPathSecrets('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
205+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('log.sensitivePathMarkers'));
206+
} finally {
207+
warnSpy.mockRestore();
208+
}
209+
});
210+
211+
test('a string value for sensitiveQueryKeys is ignored (not spread char-by-char) — defaults still apply, no throw, warns', async () => {
212+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
213+
try {
214+
const mod = await loadWithConfig({
215+
log: { sensitiveQueryKeys: 'oops' },
216+
});
217+
expect(mod.SENSITIVE_QUERY_KEYS).toEqual(['inviteToken']);
218+
expect(mod.default('/x?inviteToken=secret')).toBe('/x?inviteToken=REDACTED');
219+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('log.sensitiveQueryKeys'));
220+
} finally {
221+
warnSpy.mockRestore();
222+
}
223+
});
224+
});
225+
226+
/**
227+
* Lockstep guard (#3953): `config/defaults/development.config.js` must never
228+
* declare `log.sensitivePathMarkers` / `log.sensitiveQueryKeys` again, not
229+
* even as `[]`. `deepMerge` (config/index.js) replaces arrays wholesale
230+
* instead of merging them, so a Layer-2 (global defaults) value — including
231+
* an explicit empty array — would silently clobber a Layer-1 (module config)
232+
* extension of the same key. Omitting the key entirely is what lets a
233+
* module's value pass through untouched to `lib/helpers/redactUrl.js`. This
234+
* test pins that fix against regression.
235+
*/
236+
describe('development.config.js lockstep guard (#3953)', () => {
237+
test('does not declare log.sensitivePathMarkers', () => {
238+
expect(Object.prototype.hasOwnProperty.call(developmentConfig.log, 'sensitivePathMarkers')).toBe(false);
239+
});
240+
241+
test('does not declare log.sensitiveQueryKeys', () => {
242+
expect(Object.prototype.hasOwnProperty.call(developmentConfig.log, 'sensitiveQueryKeys')).toBe(false);
243+
});
244+
});

0 commit comments

Comments
 (0)