Skip to content

Commit 5c43bb1

Browse files
fix(log): redact single-use path tokens in the morgan access log (#3955)
redactUrl() now applies redactPathSecrets() on both branches (with and without a query string) so reset/verify-email/invitation tokens embedded in the URL path never reach the access log. Closes #3951 Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup
1 parent 7c7c0bf commit 5c43bb1

3 files changed

Lines changed: 43 additions & 12 deletions

File tree

lib/helpers/redactUrl.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,19 @@ const redactPathSecrets = (path) => {
4040
};
4141

4242
/**
43-
* @desc Redact sensitive query-string parameters from a request URL for logging.
44-
* Preserves the path and every other query parameter; only the value of a
45-
* sensitive key is replaced with `REDACTED`. Tolerant of a missing/empty URL and
46-
* URLs with no query string (returned unchanged). Pure + synchronous so it is
47-
* safe to call on every logged request.
43+
* @desc Redact single-use secrets from a request URL for logging: sensitive
44+
* PATH segments (see `redactPathSecrets`) AND sensitive query-string
45+
* parameters. Preserves every other path segment and query parameter; only
46+
* matching path segments / query values are replaced with `REDACTED`.
47+
* Tolerant of a missing/empty URL and URLs with no query string. Pure +
48+
* synchronous so it is safe to call on every logged request.
4849
* @param {String} url - the raw request URL (path + optional query string)
49-
* @returns {String} the URL with sensitive query values redacted
50+
* @returns {String} the URL with sensitive path segments and query values redacted
5051
*/
5152
const redactUrl = (url) => {
5253
if (!url || typeof url !== 'string') return url;
5354
const queryStart = url.indexOf('?');
54-
if (queryStart === -1) return url;
55+
if (queryStart === -1) return redactPathSecrets(url);
5556

5657
const pathPart = url.slice(0, queryStart);
5758
const queryPart = url.slice(queryStart + 1);
@@ -66,7 +67,7 @@ const redactUrl = (url) => {
6667
})
6768
.join('&');
6869

69-
return `${pathPart}?${redactedQuery}`;
70+
return `${redactPathSecrets(pathPart)}?${redactedQuery}`;
7071
};
7172

7273
export default redactUrl;

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,43 @@ describe('redactUrl', () => {
1414
expect(redactUrl('/x?inviteToken=secret&bar=2')).toBe('/x?inviteToken=REDACTED&bar=2');
1515
});
1616

17-
test('leaves a URL without a query string unchanged', () => {
17+
test('leaves a non-sensitive URL without a query string unchanged', () => {
1818
expect(redactUrl('/api/auth/signup')).toBe('/api/auth/signup');
1919
});
2020

2121
test('leaves a URL whose query has no sensitive key unchanged', () => {
2222
expect(redactUrl('/api/users?page=2&perPage=10')).toBe('/api/users?page=2&perPage=10');
2323
});
2424

25+
test('redacts a reset-token path with no query string', () => {
26+
expect(redactUrl('/api/auth/reset/SECRETTOKEN')).toBe('/api/auth/reset/REDACTED');
27+
});
28+
29+
test('redacts a verify-email-token path with no query string', () => {
30+
expect(redactUrl('/api/auth/verify-email/SECRETTOKEN')).toBe('/api/auth/verify-email/REDACTED');
31+
});
32+
33+
test('redacts an invitation verify-token path with no query string', () => {
34+
expect(redactUrl('/api/auth/invitations/verify/SECRETTOKEN')).toBe('/api/auth/invitations/verify/REDACTED');
35+
expect(redactUrl('/api/invitations/verify/SECRETTOKEN')).toBe('/api/invitations/verify/REDACTED');
36+
});
37+
38+
test('redacts a reset-token path when a query string is also present', () => {
39+
expect(redactUrl('/api/auth/reset/SECRETTOKEN?foo=1')).toBe('/api/auth/reset/REDACTED?foo=1');
40+
});
41+
42+
test('redacts a verify-email-token path when a query string is also present', () => {
43+
expect(redactUrl('/api/auth/verify-email/SECRETTOKEN?foo=1')).toBe('/api/auth/verify-email/REDACTED?foo=1');
44+
});
45+
46+
test('redacts an invitation verify-token path when a query string is also present', () => {
47+
expect(redactUrl('/api/auth/invitations/verify/SECRETTOKEN?foo=1')).toBe('/api/auth/invitations/verify/REDACTED?foo=1');
48+
});
49+
50+
test('redacts both a path secret and a query secret in the same URL', () => {
51+
expect(redactUrl('/api/auth/reset/SECRETTOKEN?inviteToken=abc123')).toBe('/api/auth/reset/REDACTED?inviteToken=REDACTED');
52+
});
53+
2554
test('handles a bare sensitive key with no value (no = sign)', () => {
2655
// `?inviteToken` with no value: still scrubbed (becomes inviteToken=REDACTED) so a
2756
// malformed/edge query can never leak a partial token form downstream.

lib/services/express.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,10 @@ const initMiddleware = (app) => {
198198
morgan.token('email', (req) => _.get(req, 'user.email') || 'Unknown email');
199199
morgan.token('requestId', (req) => req.id || '-');
200200
morgan.token('orgId', (req) => _.get(req, 'organization.id') || _.get(req, 'organization._id', '-'));
201-
// Override the built-in :url token to scrub single-use secrets (e.g. inviteToken)
202-
// from the query string before they hit the log stream. Both the dev and prod
203-
// log patterns interpolate :url, so the override covers every logged request.
201+
// Override the built-in :url token to scrub single-use secrets — both path
202+
// tokens (e.g. /auth/reset/:token) and query params (e.g. inviteToken) —
203+
// before they hit the log stream. Both the dev and prod log patterns
204+
// interpolate :url, so the override covers every logged request.
204205
morgan.token('url', (req) => redactUrl(req.originalUrl || req.url));
205206
app.use(morgan(logger.getLogFormat(), logger.getMorganOptions()));
206207
}

0 commit comments

Comments
 (0)