Merge pull request #542 from MBombeck/release/v1.30.18

release/v1.30.18 — health data and credentials no longer reach the logs or the backups
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 01:39:34 +02:00
commit 41ae510271
10 files changed
+350 -14

No files matched your search

+12
View File
@@ -2,6 +2,18 @@
## [Unreleased]
## [1.30.18] — 2026-07-19
Health data and credentials no longer reach the logs or the backups.
- **Cached responses are encrypted at rest.** A write endpoint's response is kept for 24 hours so a retried request cannot run twice. Those responses echo what was just saved — cycle notes, mood text, allergy reactions — and were stored unencrypted, in a column that lands in every backup. They are now encrypted like every other stored health field. If encryption is unavailable the response is simply not cached, rather than being written in the clear.
- **Diagnostic log entries are scrubbed at the point they are written.** Two of the places that build a log entry did not run the redaction the others did, so an outbound request that timed out could put a notification bot's credential into the logs verbatim.
- **A failed AI request no longer quotes what the provider sent back.** Some providers echo the request they rejected, which on this path contains the prompt and the health figures in it. The log entry now names the kind of rejection instead of quoting the response, which answers the same diagnostic question without carrying the content.
Operator note: no action required, and no configuration change. Log lines and backups written before this release may contain the values described above.
No breaking changes.
## [1.30.17] — 2026-07-19
API tokens now enforce their scope.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.17
version: 1.30.18
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.17",
"version": "1.30.18",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.17";
/* @sw-version-fallback */ "v1.30.18";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+31
View File
@@ -1,4 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The replay cache encrypts the stored body (it echoes decrypted PHI on the
// create paths). Give the suite a real key so these cases exercise the
// encrypted path rather than the skip-caching fallback.
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "0".repeat(64));
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -486,4 +493,28 @@ describe("isCachableStatus do-not-cache rules (V3 audit)", () => {
expect(isCachableStatus(503)).toBe(false);
expect(isCachableStatus(504)).toBe(false);
});
it("never stores the response body in cleartext", async () => {
// The create paths echo their own decrypted DTO, so the replay cache held
// cycle notes, mood text and allergy reactions in the clear for 24 hours -
// in a column that lands in every backup. The secret-shaped-body guard did
// not catch it because health data is not secret-SHAPED.
const PHI = "felt low on Tuesday, cramps returned";
const handler = vi.fn(async () =>
NextResponse.json({ data: { note: PHI }, error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "phi-12345678" }));
const stored = (
vi.mocked(prisma.idempotencyKey.updateMany).mock.calls[0][0] as {
data: { responseBody: string };
}
).data.responseBody;
expect(stored).not.toContain(PHI);
expect(stored).not.toContain("cramps");
expect(stored.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { classifyErrorBody } from "../provider-runner";
/**
* An upstream error body never reaches the logs verbatim.
*
* The provider chain used to put the first 500 characters of whatever the
* remote returned into a wide-event meta key. That is fine for a terse vendor
* error and catastrophic for a gateway that echoes the offending request on a
* 400 — the standard shape for `context_length_exceeded` — because on this path
* the request is the coach system prompt plus the user's health snapshot.
*
* Scrubbing cannot fix this: there is no pattern that distinguishes a person's
* weight history from any other text. So the body is classified rather than
* quoted, and these tests pin that no input content survives.
*/
/** Health content of the kind a gateway echo would carry. */
const ECHOED_PROMPT =
'context_length_exceeded: request was {"messages":[{"role":"system","content":"You are writing a short assessment. Weight 82.4 kg, resting pulse 61 bpm, ramipril 5mg daily, mood note: felt low on Tuesday"}]}';
describe("upstream error-body classification", () => {
it("names a known rejection without quoting the body", () => {
const out = classifyErrorBody(ECHOED_PROMPT);
expect(out).toBe("classified:context_length_exceeded");
});
it("carries no health content from an echoed prompt", () => {
const out = classifyErrorBody(ECHOED_PROMPT) ?? "";
for (const leak of [
"82.4",
"ramipril",
"felt low on Tuesday",
"resting pulse",
"messages",
]) {
expect(out, `classification leaked "${leak}"`).not.toContain(leak);
}
});
it("reports only a size for an unrecognised body", () => {
// Still enough to tell "the same failure every hop" from "a different one
// each time", which is the actual diagnostic question. The expected length
// is measured here from the literal, not read back from the implementation.
const body = "something entirely unexpected from a proxy";
expect(classifyErrorBody(body)).toBe(`unclassified:${body.length}chars`);
});
it("never returns a substring of an unrecognised body", () => {
const secretish = "internal-hostname-10-1-2-3 said no";
const out = classifyErrorBody(secretish) ?? "";
expect(out).not.toContain("internal-hostname");
expect(out).not.toContain("said no");
});
it("classifies the other known shapes", () => {
expect(classifyErrorBody("429 rate limit reached")).toBe(
"classified:rate_limited",
);
expect(classifyErrorBody("the model_not_found for gpt-x")).toBe(
"classified:model_not_found",
);
expect(classifyErrorBody("invalid response_format schema")).toBe(
"classified:response_format_rejected",
);
expect(classifyErrorBody("insufficient_quota on this key")).toBe(
"classified:quota_or_billing",
);
expect(classifyErrorBody("invalid_api_key supplied")).toBe(
"classified:auth_rejected",
);
});
it("passes through absence honestly", () => {
expect(classifyErrorBody(null)).toBeNull();
expect(classifyErrorBody("")).toBeNull();
expect(classifyErrorBody(undefined)).toBeNull();
});
});
+47 -7
View File
@@ -337,13 +337,15 @@ function summariseError(e: unknown): {
};
const status = typeof err.httpStatus === "number" ? err.httpStatus : null;
const message = err.message ?? "unknown error";
// v1.21.3 — surface the provider's redacted response body (the codex client
// attaches it) so the wide-event carries the upstream's actual rejection
// reason, not just "HTTP 400". Already redacted of secrets at the client.
const bodyExcerpt =
typeof err.bodyExcerpt === "string" && err.bodyExcerpt.length > 0
? err.bodyExcerpt.slice(0, 500)
: null;
// v1.21.3 surfaced the upstream's response body so the wide event carried the
// real rejection reason rather than a bare "HTTP 400". The excerpt was raw
// upstream text, and a gateway that echoes the offending request on a 400 —
// the standard shape for `context_length_exceeded` — echoes the prompt, which
// on this path is the coach system prompt plus the health snapshot. No
// pattern can make arbitrary remote text safe to log, so the body is
// CLASSIFIED instead of quoted: the diagnostic question ("which rejection is
// this, and is it the same one recurring?") is answered without the content.
const bodyExcerpt = classifyErrorBody(err.bodyExcerpt);
// Cap to keep wide-event payloads bounded.
return {
reason: status !== null ? `HTTP ${status}: ${message}` : message,
@@ -352,6 +354,44 @@ function summariseError(e: unknown): {
};
}
/**
* Reduce an upstream error body to a non-quoting descriptor.
*
* Known rejection shapes are named — those strings are our own vocabulary, not
* the remote's. Anything else reports only its size, which still distinguishes
* "the same rejection every hop" from "a different failure each time" without
* carrying a single character of remote content.
*/
export function classifyErrorBody(body: unknown): string | null {
if (typeof body !== "string" || body.length === 0) return null;
const lowered = body.toLowerCase();
if (
lowered.includes("context_length_exceeded") ||
lowered.includes("too many tokens")
) {
return "classified:context_length_exceeded";
}
if (lowered.includes("rate limit") || lowered.includes("rate_limit")) {
return "classified:rate_limited";
}
if (
lowered.includes("model_not_found") ||
lowered.includes("unknown model")
) {
return "classified:model_not_found";
}
if (lowered.includes("response_format") || lowered.includes("json_schema")) {
return "classified:response_format_rejected";
}
if (lowered.includes("insufficient_quota") || lowered.includes("billing")) {
return "classified:quota_or_billing";
}
if (lowered.includes("invalid_api_key") || lowered.includes("unauthorized")) {
return "classified:auth_rejected";
}
return `unclassified:${body.length}chars`;
}
/**
* Run an insight generation across an ordered chain of providers,
* walking past hard provider failures. Schema-class failures (422)
+57 -2
View File
@@ -15,6 +15,7 @@ import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import { decrypt, encrypt } from "@/lib/crypto";
const TTL_MS = 24 * 60 * 60 * 1000;
/**
@@ -97,7 +98,11 @@ async function findCached(ctx: IdempotencyContext): Promise<CacheLookup> {
let parsed: unknown = null;
try {
parsed = JSON.parse(row.responseBody);
// Rows written before the body was encrypted are plaintext JSON. Those
// expire within the 24h replay window, so rather than migrate them, try
// ciphertext first and fall back to a direct parse; the fallback becomes
// dead within a day of deploy and is safe to remove after that.
parsed = JSON.parse(decryptCachedBody(row.responseBody));
} catch {
parsed = null;
}
@@ -167,6 +172,46 @@ async function releaseClaim(ctx: IdempotencyContext): Promise<void> {
.catch(() => {});
}
/**
* Encrypt a cached response body at rest.
*
* The replay cache stores the response verbatim, and the PHI-returning creates
* echo their own decrypted DTO — cycle day-log free text, reproductive-intent
* fields, mood notes, allergy reactions. That is exactly what the `*Encrypted`
* columns exist to protect, and it was sitting in cleartext for 24 hours in a
* column that lands in every backup. The secret-shaped-body guard did not catch
* it because health data is not secret-SHAPED.
*/
function encryptCachedBody(body: string): string | null {
if (body.length === 0) return body;
try {
return encrypt(body);
} catch {
// The crypto loader is deliberately fail-closed: no key, malformed key map
// or unknown key id all throw rather than silently writing plaintext. The
// replay cache is an accelerator, not a correctness guarantee, so the right
// response is to skip caching — never to store the body unprotected, and
// never to fail the caller's write over a cache we could not populate.
return null;
}
}
/**
* Read a cached body back, tolerating rows written before encryption.
*
* `decrypt` throws on anything that is not a well-formed envelope, so a legacy
* plaintext row falls through to being returned as-is. Those rows age out
* within the 24h replay window.
*/
function decryptCachedBody(stored: string): string {
if (stored.length === 0) return stored;
try {
return decrypt(stored);
} catch {
return stored;
}
}
async function persistCached(
ctx: IdempotencyContext,
response: Response,
@@ -179,6 +224,16 @@ async function persistCached(
.text()
.catch(() => ""));
const storedBody = encryptCachedBody(body);
if (storedBody === null) {
// Could not encrypt — drop the claim rather than cache the body in the
// clear. A later retry misses and re-runs the handler, which is the same
// behaviour as any other cache miss.
annotate({ meta: { idempotency_cache_skipped: "encryption_unavailable" } });
await releaseClaim(ctx);
return;
}
// Promote the claimed pending row to the completed response, extending
// the TTL from the short claim window to the full 24h replay window.
// The claim is held under the unique constraint, so this caller owns
@@ -193,7 +248,7 @@ async function persistCached(
},
data: {
responseStatus: response.status,
responseBody: body,
responseBody: storedBody,
expiresAt: new Date(Date.now() + TTL_MS),
},
})
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { WideEventBuilder } from "../event-builder";
/**
* Nothing leaves the wide-event builder unscrubbed.
*
* `setError` and `setHttp` have always scrubbed. `addMeta` and
* `addExternalCall` did not, which made them a trusted sink by accident: the
* finished event is JSON-stringified whole to stdout and the log store, so a
* string placed in meta is as visible as one placed in an error message.
*
* Two real feeds exploited that gap. A Telegram request timeout put the full
* request URL — which embeds the bot token — into `external_calls[].error`. And
* the AI provider chain fed upstream error bodies into meta, so a gateway that
* echoes the offending request on a 400 echoed prompt content.
*
* These tests fail on the previous code.
*/
/** Read the finished event the way a transport would. */
function emitted(b: WideEventBuilder): string {
return JSON.stringify(b.toJSON());
}
describe("wide-event builder — redaction at every entry point", () => {
it("scrubs a bot token that arrives through addExternalCall", () => {
const b = new WideEventBuilder("http");
b.addExternalCall({
service: "telegram",
method: "sendMessage",
duration_ms: 12,
error:
"timeout of 5000ms exceeded: https://api.telegram.org/bot123456:AAH-SuperSecretBotTokenValue/sendMessage",
});
expect(emitted(b)).not.toContain("AAH-SuperSecretBotTokenValue");
});
it("scrubs an API key that arrives through addMeta", () => {
const b = new WideEventBuilder("http");
b.addMeta(
"ai_chain_hop_1_body",
"401 unauthorized: key sk-abcdef1234567890",
);
expect(emitted(b)).not.toContain("sk-abcdef1234567890");
});
it("scrubs strings nested inside an object or array", () => {
// The emitted JSON flattens structure — a nested string is exactly as
// exposed as a top-level one, so the walk has to reach it.
const b = new WideEventBuilder("http");
b.addMeta("payload", {
hops: [{ detail: "token sk-nested9876543210 rejected" }],
});
expect(emitted(b)).not.toContain("sk-nested9876543210");
});
it("leaves ordinary diagnostic values intact", () => {
// Over-redaction would make the logs useless, which is its own failure.
const b = new WideEventBuilder("http");
b.addMeta("count", 42);
b.addMeta("action", "coach.budget.exceeded");
const out = emitted(b);
expect(out).toContain("coach.budget.exceeded");
expect(out).toContain("42");
});
it("bounds pathological nesting instead of recursing without limit", () => {
// A cyclic or absurdly nested value must not turn a log write into a crash.
let deep: Record<string, unknown> = { leaf: "sk-deepvalue12345678" };
for (let i = 0; i < 40; i++) deep = { next: deep };
const b = new WideEventBuilder("http");
expect(() => b.addMeta("deep", deep)).not.toThrow();
expect(emitted(b)).not.toContain("sk-deepvalue12345678");
});
});
+44 -2
View File
@@ -4,6 +4,36 @@ import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";
/**
* Apply `redactSecrets` to every string a value contains, at any depth.
*
* The builder's contract is that nothing leaves through it unscrubbed. Strings
* nested inside an object or array are just as visible in the emitted JSON as a
* top-level one, so the walk has to reach them. Non-strings pass through
* untouched; the structure is rebuilt rather than mutated so a caller's object
* is never modified behind its back.
*
* The depth bound stops a cyclic or pathologically nested value from turning a
* log write into a stack overflow. Anything past it is dropped rather than
* emitted unscrubbed — an unreadable log line is recoverable, a leaked
* credential is not.
*/
function redactDeep<T>(value: T, depth = 0): T {
if (depth > 8) return "[redacted: too deep]" as unknown as T;
if (typeof value === "string") return redactSecrets(value) as unknown as T;
if (Array.isArray(value)) {
return value.map((v) => redactDeep(v, depth + 1)) as unknown as T;
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = redactDeep(v, depth + 1);
}
return out as unknown as T;
}
return value;
}
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
* Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert.
@@ -97,7 +127,13 @@ export class WideEventBuilder {
addExternalCall(call: NonNullable<WideEvent["external_calls"]>[0]): this {
if (!this.event.external_calls) this.event.external_calls = [];
this.event.external_calls.push(call);
// An outbound failure carries whatever the remote said, and for several
// integrations that is the request URL — which for Telegram embeds the bot
// token. `setError` and `setHttp` scrub on the way in; this entry point did
// not, so a credential that is encrypted at rest reached stdout and the log
// store in plaintext. Scrub here too: the redaction contract is the
// builder's, not each caller's.
this.event.external_calls.push(redactDeep(call));
return this;
}
@@ -116,7 +152,13 @@ export class WideEventBuilder {
addMeta(key: string, value: unknown): this {
if (!this.event.meta) this.event.meta = {};
this.event.meta[key] = value;
// Meta is emitted verbatim: the finished event is JSON-stringified whole to
// stdout and the log store. It was the one builder entry point that did not
// scrub, which made it a trusted sink by accident rather than by design —
// and the AI provider chain feeds upstream error bodies through it, so a
// gateway that echoes the offending request put prompt content and
// non-standard credentials into the logs. Scrub every string, at any depth.
this.event.meta[key] = redactDeep(value);
return this;
}