diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index efe1a25e..d9a50863 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -169,16 +169,32 @@ A refused write names the reason and the unmask that lifts it. The panel, `form- - password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential, credentials, cookie, authorization and jwt. Names are split on camelCase and punctuation, so userPassword and card_number both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber, securityCode, sessionId and sessionKey match as well. Every inspector uses this list. + password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential, credentials, cookie, authorization and jwt. Names are split on camelCase and punctuation, so userPassword and card_number both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber, securityCode, sessionId, sessionKey, authHeader, authKey, authCode and basicAuth match as well, and so does a name that is only bearer. auth on its own is not secret, so an auth state slice stays readable, but credentials inside it are still masked by value (see Credentials inside values). Every inspector uses this list. +### Credentials inside values + +Some credentials are hidden wherever they appear in a string, whatever the key is called. Every inspector, the panel and the agent tools get the same masking: + +| Value | Shown as | +| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| A URL with a password, such as `https://alice:pw@host` | `https://alice:[redacted]@host` | +| `Basic` followed by base64 of `user:password` | `Basic [redacted]` | +| `Digest` followed by its `name="value"` parameters | `Digest [redacted]` | +| `Token` followed by an opaque token with letters and digits | `Token [redacted]` | +| `Bearer` followed by a token | `Bearer [redacted]` | +| Any of `Basic`, `Bearer`, `Digest`, `Token`, `Negotiate`, `NTLM`, `ApiKey` and similar after `Authorization:` or `Proxy-Authorization:` | the scheme, then `[redacted]` | +| A JWT | `[redacted]` | + +The URL user stays visible, so you can tell which account a call uses. Ordinary words are left alone: `Basic plan`, `Token expired` and `Digest of the week` don't match. + ### Router These are replaced with `[redacted]` in URLs, params, data and messages: - query, matrix and fragment keys that look secret (token, password, api key, code, sig, session, jwt and similar), including inside encoded return URLs, -- JWTs, bearer tokens and long opaque tokens, +- URL passwords, `Authorization` credentials, JWTs, bearer tokens and long opaque tokens, - route params with secret-looking names. A secret route param is known from the route config before a navigation is recognized, so link targets and a navigation that was already running when the overlay attached are masked too. A navigation that fails before that (for example inside a lazy route that failed to load) can still show it in its URL. JWTs are masked before long values are cut, so a long token never leaves a readable start behind. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 29b02700..549f8186 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -85,7 +85,7 @@ An offline HTML build of the panel over the source scan, written by `pangular bu _Avoid_: export, snapshot, static site **Redaction**: -Masking values before they reach the panel or an agent: field names that look secret (plus `redaction.secretNames`), JWTs and bearer tokens. It runs in `serialize` and the redaction helpers, and `redaction.unmask` lists the names to show anyway. +Masking values before they reach the panel or an agent: field names that look secret (plus `redaction.secretNames`), URL passwords, `Authorization` credentials (`Basic`, `Digest`, `Token`), JWTs and bearer tokens. It runs in `serialize` and the redaction helpers, and `redaction.unmask` lists the names to show anyway. _Avoid_: sanitizing, scrubbing, hiding **One-time code**: diff --git a/packages/devtools/src/__tests__/credential-redaction.test.ts b/packages/devtools/src/__tests__/credential-redaction.test.ts new file mode 100644 index 00000000..2ce0f4ac --- /dev/null +++ b/packages/devtools/src/__tests__/credential-redaction.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { redactMessage as serverLogMessage } from '../analog-server-log.ts'; +import { REDACTED, isRedactedKey, isSecretKey, redactMessage } from '../forms-privacy.ts'; +import type { HttpCall } from '../http-rules.ts'; +import { redactCall, redactPreview } from '../http-redact.ts'; +import { serialize as ngrxSerialize } from '../ngrx-shared.ts'; +import { + isSecretKey as isRouterSecretKey, + redactRecord, + redactText, + redactUrl, +} from '../router.ts'; +import { serialize } from '../serialize.ts'; + +const basic = `Basic ${btoa('alice:hunter22')}`; + +describe('URL passwords', () => { + const url = 'https://alice:hunter22@api.example.com/v1/users?page=2'; + + it('hides the password and keeps the user in every helper', () => { + const safe = `https://alice:${REDACTED}@api.example.com/v1/users?page=2`; + expect(redactUrl(url)).toBe(safe); + expect(redactText(url)).toBe(safe); + expect(redactMessage(url)).toBe(safe); + expect(serialize(url)).toBe(safe); + expect(ngrxSerialize({ dsn: url })).toEqual({ dsn: safe }); + expect(serverLogMessage(`GET ${url}`)).toBe(`GET ${safe}`); + }); + + it('hides it in recorded HTTP calls and inside text', () => { + const call = { url, pageUrl: url, error: `failed to reach ${url}` } as HttpCall; + const safe = redactCall(call); + for (const text of [safe.url, safe.pageUrl, safe.error]) { + expect(text).toContain(`alice:${REDACTED}@`); + expect(text).not.toContain('hunter22'); + } + expect(redactPreview(JSON.stringify({ db: 'postgres://app:s3cr3t@db:5432/x' }))).toBe( + JSON.stringify({ db: `postgres://app:${REDACTED}@db:5432/x` }), + ); + expect(redactMessage('mongodb+srv://u:p%40ss@cluster0.example.net/db')).toBe( + `mongodb+srv://u:${REDACTED}@cluster0.example.net/db`, + ); + }); + + it('leaves URLs without a password alone', () => { + for (const text of [ + 'ssh://git@github.com/org/repo.git', + 'https://example.com/users/@alice', + 'http://localhost:4200/a:b@c', + 'mailto:alice@example.com', + 'https://example.com:8443/path', + ]) { + expect(redactUrl(text), text).toBe(text); + expect(redactMessage(text), text).toBe(text); + } + }); +}); + +describe('Authorization credentials', () => { + it('hides Basic, Digest and Token credentials and keeps the scheme', () => { + expect(redactMessage(basic)).toBe(`Basic ${REDACTED}`); + expect(redactMessage('Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b')).toBe( + `Token ${REDACTED}`, + ); + expect( + redactMessage( + 'Digest username="alice", realm="api", nonce="dcd98b", response="6629fae49393a05397450978507c4ef1"', + ), + ).toBe(`Digest ${REDACTED}`); + expect(serialize(`sent ${basic.replace('Basic', 'basic')}`)).toBe(`sent basic ${REDACTED}`); + }); + + it('hides any known scheme after an Authorization header name', () => { + expect(redactText('Authorization: Negotiate YIIBhwYGKwYBBQUCoIIBezCCAXeg')).toBe( + `Authorization: Negotiate ${REDACTED}`, + ); + expect(serverLogMessage('proxy-authorization=NTLM TlRMTVNTUAABAAAAB4IIog')).toBe( + `proxy-authorization=NTLM ${REDACTED}`, + ); + expect(redactPreview('{"headers":{"Authorization":"Basic abc')).not.toContain('abc'); + }); + + it('hides credentials in values whatever the key is called', () => { + expect(serialize({ header: basic, auth: basic })).toEqual({ + header: `Basic ${REDACTED}`, + auth: `Basic ${REDACTED}`, + }); + expect(redactRecord({ h: 'Token abcd1234efgh5678' })).toEqual({ h: `Token ${REDACTED}` }); + }); + + it('leaves ordinary words alone', () => { + for (const text of [ + 'Basic usage of the API', + 'Basic plan', + 'Token expired, please sign in again', + 'Token refresh failed', + 'Digest of the week', + 'authorization: required for this endpoint', + ]) { + expect(redactMessage(text), text).toBe(text); + expect(redactText(text), text).toBe(text); + } + }); +}); + +describe('secret key names', () => { + const masked = [ + 'authorization', + 'Authorization', + 'proxy-authorization', + 'bearer', + 'Bearer', + 'bearerToken', + 'x-api-key', + 'apiKey', + 'api_key', + 'access_token', + 'accessToken', + 'refresh_token', + 'client_secret', + 'clientSecret', + 'authToken', + 'authHeader', + 'authKey', + 'auth_code', + 'basicAuth', + ]; + const kept = [ + 'author', + 'authorName', + 'authors', + 'authored', + 'authority', + 'authorize', + 'authorized', + 'oauthProvider', + 'authStatus', + 'isAuthenticated', + 'keyboard', + 'monkey', + ]; + + it('masks credential-looking keys', () => { + for (const key of masked) { + expect(isSecretKey(key), key).toBe(true); + expect(isRedactedKey(key), key).toBe(true); + expect(isRouterSecretKey(key), key).toBe(true); + } + expect(serialize({ bearer: 'abc', apiKey: 'k' })).toEqual({ + bearer: REDACTED, + apiKey: REDACTED, + }); + }); + + it('keeps ordinary names that only start like a secret one', () => { + for (const key of kept) { + expect(isSecretKey(key), key).toBe(false); + expect(isRedactedKey(key), key).toBe(false); + } + expect(serialize({ author: 'Ada', authorName: 'Ada Lovelace' })).toEqual({ + author: 'Ada', + authorName: 'Ada Lovelace', + }); + }); +}); diff --git a/packages/devtools/src/analog-server-log.ts b/packages/devtools/src/analog-server-log.ts index 08ae9a5b..3a327070 100644 --- a/packages/devtools/src/analog-server-log.ts +++ b/packages/devtools/src/analog-server-log.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { redactCredentials } from './credential-redact.ts'; import { JWT, isRedactedKey } from './forms-privacy.ts'; import { redactJsonText } from './json-text-redact.ts'; @@ -18,7 +19,7 @@ function queryKey(key: string): string { } export function redactMessage(text: string): string { - return text + return redactCredentials(text, '[redacted]') .replace(JWT, '[redacted]') .replace(BEARER, 'Bearer [redacted]') .replace(QUERY_PAIR, (whole, sep: string, key: string) => { diff --git a/packages/devtools/src/credential-redact.ts b/packages/devtools/src/credential-redact.ts new file mode 100644 index 00000000..903e7335 --- /dev/null +++ b/packages/devtools/src/credential-redact.ts @@ -0,0 +1,53 @@ +/** + * Credentials that ride inside text rather than under a secret key: the + * password in a URL's userinfo (`https://user:pass@host`) and the credential of + * an `Authorization` value (`Basic …`, `Digest …`, `Token …`). Bearer tokens + * and JWTs have their own patterns next to the callers. + */ + +/** `scheme://user:password@`; the user is kept, the password is hidden. */ +const USERINFO = /\b([a-z][a-z\d+.-]*:\/\/)([^\s/?#@:"'<>\\]*):([^\s/?#@"'<>\\]+)@/gi; + +/** The credential after a known scheme in an `Authorization:` or `authorization=` value. */ +const AUTH_HEADER = + /\b((?:proxy-)?authorization)(["']?\s*[:=]\s*["']?)(Basic|Bearer|Digest|Token|Negotiate|NTLM|ApiKey|Key|DPoP|Hawk|HOBA|Mutual|AWS4-HMAC-SHA256|SCRAM-SHA-(?:1|256)|vapid)([ \t]+)([^\s"'\\][^"'\\\r\n]*)/gi; + +/** `Basic `. */ +const BASIC = /\b(Basic)([ \t]+)([A-Za-z\d+/]{4,}={0,2})(?![\w+/=])/gi; + +/** `Digest name="value", …`: the parameter list carries the response hash. */ +const DIGEST = + /\b(Digest)([ \t]+)\w+=(?:"[^"\r\n]*"|[^\s,"]+)(?:\s*,\s*\w+=(?:"[^"\r\n]*"|[^\s,"]+))*/gi; + +/** `Token ` as used by Django REST Framework and GitHub. */ +const TOKEN = /\b(Token)([ \t]+)([\w.~+/-]{8,}=*)/g; + +function decodesToPair(text: string): boolean { + try { + return typeof atob === 'function' && atob(text).includes(':'); + } catch { + return false; + } +} + +function looksOpaque(text: string): boolean { + return /\d/.test(text) && /[A-Za-z]/.test(text); +} + +export function redactCredentials(text: string, mask: string): string { + if (!text) return text; + return text + .replace(USERINFO, (_, scheme: string, user: string) => `${scheme}${user}:${mask}@`) + .replace( + AUTH_HEADER, + (whole, name: string, sep: string, scheme: string, gap: string, credential: string) => + credential.startsWith(mask) ? whole : `${name}${sep}${scheme}${gap}${mask}`, + ) + .replace(BASIC, (whole, scheme: string, gap: string, credential: string) => + decodesToPair(credential) ? `${scheme}${gap}${mask}` : whole, + ) + .replace(DIGEST, (_, scheme: string, gap: string) => `${scheme}${gap}${mask}`) + .replace(TOKEN, (whole, scheme: string, gap: string, credential: string) => + looksOpaque(credential) ? `${scheme}${gap}${mask}` : whole, + ); +} diff --git a/packages/devtools/src/forms-privacy.ts b/packages/devtools/src/forms-privacy.ts index 45e56032..b9b8a8b8 100644 --- a/packages/devtools/src/forms-privacy.ts +++ b/packages/devtools/src/forms-privacy.ts @@ -1,3 +1,5 @@ +import { redactCredentials } from './credential-redact.ts'; + export const REDACTED = '[redacted]'; export type RedactReason = 'key' | 'input-type' | 'autocomplete' | 'marker' | 'parent' | 'config'; @@ -55,6 +57,11 @@ const SECRET_PAIRS = new Set([ 'securitycode', 'sessionid', 'sessionkey', + 'authheader', + 'authkey', + 'authcode', + 'basicauth', + 'bearer', ]); const SECRET_AUTOCOMPLETE = /password|one-time-code|cc-/i; @@ -170,7 +177,9 @@ function safeQuery(element: Element, selector: string): boolean { } export function redactMessage(text: string, secrets: Iterable = []): string { - let out = text.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`); + let out = redactCredentials(text, REDACTED) + .replace(JWT, REDACTED) + .replace(BEARER, `Bearer ${REDACTED}`); const longestFirst = [...secrets].filter((secret) => secret.length >= 3); longestFirst.sort((a, b) => b.length - a.length); for (const secret of longestFirst) out = out.split(secret).join(REDACTED); diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index ede37bf1..530163ec 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -1,3 +1,4 @@ +import { redactCredentials } from './credential-redact.ts'; import { JWT } from './forms-privacy.ts'; import { REDACTED, isSensitive, serializeFormValue } from './forms.ts'; import { clip } from './text.ts'; @@ -235,7 +236,8 @@ function segmentForm(secret: string): string { /** * Hides the values of secret-looking query, matrix and fragment keys - * (`?token=…`, `;api_key=…`, `#access_token=…`), JWTs and bearer tokens, and + * (`?token=…`, `;api_key=…`, `#access_token=…`), URL passwords, `Authorization` + * credentials (`Basic`, `Digest`, `Token`), JWTs and bearer tokens, and * any of the given secret route param values, wherever they appear in a URL or * a message. In `url` mode a value runs to the next separator and long opaque * tokens (not UUIDs or ULIDs) are hidden too; in messages it also stops at quotes. @@ -246,7 +248,7 @@ export function redactText( mode: 'url' | 'text' = 'text', depth = 0, ): string { - let out = text.replace( + let out = redactCredentials(text, REDACTED).replace( mode === 'url' ? URL_PAIR : TEXT_PAIR, (match, sep: string, key: string, value: string) => { if (isSecretKey(decode(key))) return `${sep}${key}=${REDACTED}`;