mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Second-factor management has been cookie-only since v1.23. The gate is right: a Bearer token is a credential whose provenance the server cannot see, and tearing down a second factor must not be reachable from the weaker of an interactive login and a value pasted into a script. What was missing was never Bearer access to those routes — it was a way to say, over a transport with no session row, that the human holding this device just re-proved a factor. An elevation is that proof. It is minted only against a re-proved account password or primary passkey, bound to the exact token that re-proved, single-use via a conditional UPDATE, five-minute-lived to match the cookie path's step-up window, and stored hashed. A stolen token mints nothing; a second token cannot redeem another's elevation; a password change drops every one the account holds. requireMfaManagementAuth is the one gate the management routes share. On a cookie session it delegates to the original helpers unchanged, so the web flow runs the code it always did. requireAdmin stays cookie-only and out of reach.
123 lines
6.5 KiB
TypeScript
123 lines
6.5 KiB
TypeScript
/**
|
|
* Redact secrets from strings before they leave the process boundary
|
|
* (Loki, Glitchtip, console). Applied in `WideEventBuilder.setError()`,
|
|
* `WideEventBuilder.setHttp()`, and `reportToGlitchtip()` so every
|
|
* value reaching observability is scrubbed once, centrally.
|
|
*
|
|
* Patterns:
|
|
* - `Bearer <token>` — generic API tokens incl. our `hlk_` /
|
|
* `hlr_` formats and external OAuth bearers (Withings, AI providers).
|
|
* - `bot<digits>:<token>` — Telegram bot URLs embed the token in the
|
|
* path: `https://api.telegram.org/bot1234567:ABC-…/sendMessage`.
|
|
* If `fetch()` rejects with a `cause` that surfaces the URL (some
|
|
* Node runtimes), the token would land in error reports.
|
|
* - `?secret=…` and `?code=…` — query-string leaks for legacy
|
|
* Withings webhooks and OAuth callbacks (already scrubbed at the
|
|
* URL level in `reportToGlitchtip`, but error.message can carry
|
|
* the URL too).
|
|
* - `sk-…` and `sk-ant-…` — OpenAI / Anthropic API keys. We never
|
|
* log them on purpose, but a misconfigured client error or a
|
|
* dump of the request body could carry one. Scrub before egress.
|
|
* - path-segment secrets — routes like `/api/withings/webhook/<secret>`
|
|
* and the clinician share view `/c/<hls_…>` carry a bearer secret as
|
|
* a positional path segment. The Wide Event `http.path` and
|
|
* `http.route` fields would otherwise leak the secret into stdout,
|
|
* the in-memory ring buffer, and Loki. See `PATH_SECRET_PATHS`.
|
|
*
|
|
* The substitution is intentionally generic ([REDACTED]) — we don't
|
|
* want partial revelation of token entropy.
|
|
*/
|
|
/**
|
|
* Registry of path prefixes where the trailing segment is a shared
|
|
* secret. Each entry's `prefix` must end with `/`; any non-empty
|
|
* segment after it is rewritten to `[REDACTED]`. Subsequent path
|
|
* segments are preserved (so `/foo/<secret>/bar` becomes
|
|
* `/foo/[REDACTED]/bar`).
|
|
*
|
|
* Add a new entry whenever a route exposes a secret in its URL path
|
|
* (rather than headers, body, or query). Tested in `redact.test.ts`.
|
|
*/
|
|
export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [
|
|
// Withings webhook entrypoint: `WITHINGS_WEBHOOK_SECRET` travels as
|
|
// the trailing path segment (v1.4.25 W17a). Without this rule the
|
|
// secret lands in every Wide Event's `http.path` / `http.route`.
|
|
{ prefix: "/api/withings/webhook/" },
|
|
// v1.11.0 — clinician share view (Epic C). The raw `hls_` share token
|
|
// rides as the trailing path segment of `/c/<token>`; it is a bearer
|
|
// credential for the share surface, so scrub it from `http.path` /
|
|
// `http.route` the same way the Withings webhook secret is scrubbed.
|
|
{ prefix: "/c/" },
|
|
// v1.11.0 — WHOOP webhook entrypoint: `WHOOP_WEBHOOK_SECRET` travels as
|
|
// the trailing path segment (mirrors Withings). Without this rule the
|
|
// secret lands in every Wide Event's `http.path` / `http.route`.
|
|
{ prefix: "/api/whoop/webhook/" },
|
|
];
|
|
|
|
function redactPathSegments(input: string): string {
|
|
let out = input;
|
|
for (const { prefix } of PATH_SECRET_PATHS) {
|
|
// Match `<prefix><segment>` where `<segment>` is one or more
|
|
// non-`/` non-`?` chars, then preserve anything after (next path
|
|
// segment, query string, hash). Capture group 1 is the optional
|
|
// trailing context starting with `/`, `?`, or `#`.
|
|
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const re = new RegExp(`${escaped}[^/?#\\s]+([/?#][^\\s]*)?`, "g");
|
|
out = out.replace(re, (_match, tail: string | undefined) => {
|
|
return `${prefix}[REDACTED]${tail ?? ""}`;
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function redactSecrets(input: string): string {
|
|
return (
|
|
redactPathSegments(input)
|
|
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
|
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
|
|
// `sk-` is a common English-prefix substring (task-force, risk-
|
|
// management, disk-io). Without a word-boundary, the original
|
|
// `/sk-(?:ant-)?[A-Za-z0-9_-]+/g` over-matched and made server logs
|
|
// unreadable. Require the `sk-` token to be at the start of the
|
|
// string OR preceded by a non-alphanumeric character (whitespace,
|
|
// `=`, `{`, `,`, `:`, etc.). The minimum length-8 tail keeps a
|
|
// tiny safety margin against `sk-1`-style false-positives in code
|
|
// identifiers. Capture group 1 = the leading separator (preserved).
|
|
.replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]")
|
|
// HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh
|
|
// token, `hls_` session cookie secret (v1.30.32), `hle_` step-up
|
|
// elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The
|
|
// idempotency replay-cache already rejects bodies containing
|
|
// these prefixes (CLAUDE.md headless-client-API patterns); this
|
|
// is the matching egress guard for log/error surfaces.
|
|
.replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
|
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
|
|
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
|
|
// are signed, base64url-bodied, and credential-bearing, so they are
|
|
// scrubbed at the egress boundary like the native token formats above.
|
|
.replace(
|
|
/(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g,
|
|
"$1[REDACTED]",
|
|
)
|
|
// v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory-
|
|
// insurance number) added as defence-in-depth: the value is
|
|
// encrypted at rest and never deliberately logged, but a stray
|
|
// query-string leak (`?kvnr=…`) is scrubbed at the egress boundary.
|
|
// v1.12.2 — `ticket` (the one-time WHOOP connect ticket rides in the
|
|
// `GET /api/whoop/connect?ticket=…` URL): scrub it so the opaque value
|
|
// never lands in `http.path`/error strings reaching Loki/Glitchtip.
|
|
// v1.23 — second-factor / account-recovery / encrypted-export material.
|
|
// `totp`/`mfa` tickets-or-codes, `backup_code` / `recovery_code`, and the
|
|
// `passphrase` that derives the encrypted-export key must never land in
|
|
// `http.path` / error strings reaching Loki / Glitchtip.
|
|
.replace(
|
|
/([?&])(secret|code|token|ticket|api[_-]?key|insurance(?:number)?|kvnr|totp|mfa|backup[_-]?code|recovery[_-]?code|passphrase)=[^&\s]+/gi,
|
|
"$1$2=[REDACTED]",
|
|
)
|
|
);
|
|
}
|
|
|
|
/** Apply `redactSecrets` to a possibly-undefined string. */
|
|
export function redactOptional(s: string | undefined): string | undefined {
|
|
return s === undefined ? undefined : redactSecrets(s);
|
|
}
|