Merge the release with the security fixes into the backup health work

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 15:03:17 +01:00
commit da9d941a24
99 files changed
+3700 -1073

No files matched your search

@@ -3,7 +3,12 @@ import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
isJsonContentType,
} from "@/lib/api-response";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
@@ -76,9 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// body and no content-type), so the parse only runs when a JSON body is
// actually presented. An empty object is the same instance-wide sweep.
let input: unknown = {};
if (
(request.headers.get("content-type") ?? "").includes("application/json")
) {
if (isJsonContentType(request.headers.get("content-type"))) {
const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 });
if (error) return error;
input = data ?? {};
+2 -2
View File
@@ -113,7 +113,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// The per-account half of the throttle (`@/lib/auth/login-throttle`): a
// place this account has signed in from is neither counted nor held back,
// remembered browser or a phone this account signed in on is neither
// counted nor held back,
// anywhere else waits longer after each failure past the first few, up to
// fifteen minutes. Decided before the Argon2id verification, so a waiting
// attempt costs no hashing.
@@ -121,7 +122,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
user,
identifier,
request,
ip: rl.ip,
});
annotate({ meta: { login_known_source: attempt.source ?? "none" } });
if (attempt.waiting) {
@@ -1,5 +1,10 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
safeJson,
isJsonContentType,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -35,7 +40,7 @@ export const POST = apiHandler(async (request: Request) => {
// The body is optional (the web client sends its page transport; nothing
// else needs to send anything). A missing or unreadable body is no body, as
// before; only a stated plain-http page refuses, before a challenge exists.
if (request.headers.get("content-type")?.includes("application/json")) {
if (isJsonContentType(request.headers.get("content-type"))) {
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
@@ -21,6 +21,7 @@ import {
apiSuccess,
getClientIp,
safeJson,
isJsonContentType,
} from "@/lib/api-response";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -32,7 +33,7 @@ const proofRequired = () =>
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
if (!request.headers.get("content-type")?.includes("application/json")) {
if (!isJsonContentType(request.headers.get("content-type"))) {
return proofRequired();
}
+2 -2
View File
@@ -17,7 +17,7 @@
* grants.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiError, apiSuccess, isJsonContentType } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -47,7 +47,7 @@ export const POST = apiHandler(async (req: Request) => {
// empty object, the retired `{ intent }` shape) is not the consent act.
let confirmed = false;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
const parsed = await parseJsonBody(req, webConsentGrantBody, {
maxBytes: 1024,
action: "consent.ai.web-grant.invalid_shape",
+7 -2
View File
@@ -7,7 +7,12 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
isJsonContentType,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { markDisconnected } from "@/lib/integrations/status";
@@ -23,7 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// The body is optional: no body is `{ deleteData: false }`.
let body: unknown = {};
if (request.headers.get("content-type")?.includes("application/json")) {
if (isJsonContentType(request.headers.get("content-type"))) {
const read = await safeJson(request, { maxBytes: 1024 });
if (read.error) return read.error;
body = read.data;
+2 -1
View File
@@ -13,6 +13,7 @@ import {
apiSuccess,
returnAllZodIssues,
safeJson,
isJsonContentType,
} from "@/lib/api-response";
import { loadDawarichConnection } from "@/lib/dawarich/connection";
import { yesterdayKey } from "@/lib/dawarich/sync";
@@ -34,7 +35,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// The body is optional: no body is the nightly window.
let body: unknown = {};
if (request.headers.get("content-type")?.includes("application/json")) {
if (isJsonContentType(request.headers.get("content-type"))) {
const read = await safeJson(request, { maxBytes: 1024 });
if (read.error) return read.error;
body = read.data;
@@ -50,6 +50,7 @@ import {
getClientIp,
safeJson,
sanitiseZodIssues,
isJsonContentType,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
@@ -152,7 +153,7 @@ export const POST = apiHandler(
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
// One body read for both JSON modes, BEFORE any bucket charge — a
// malformed body never needs a refund because nothing was charged yet.
const { data: body, error: jsonError } = await safeJson(request, {
@@ -40,6 +40,7 @@ import {
getClientIp,
safeJson,
sanitiseZodIssues,
isJsonContentType,
} from "@/lib/api-response";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
@@ -92,7 +93,7 @@ export const POST = apiHandler(
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
return handleTextIndex(request, user.id, document, background);
}
return handleVisionIndex(request, user.id, document, background);
@@ -30,6 +30,7 @@ import {
getClientIp,
safeJson,
sanitiseZodIssues,
isJsonContentType,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
@@ -94,7 +95,7 @@ export const POST = apiHandler(
const background = prefersRespondAsync(request);
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
return handleTextSuggest(request, user.id, document, background);
}
return handleVisionSuggest(request, user.id, document, background);
@@ -40,6 +40,7 @@ import {
getClientIp,
safeJson,
sanitiseZodIssues,
isJsonContentType,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
@@ -165,7 +166,7 @@ export const POST = apiHandler(
background: prefersRespondAsync(request),
};
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
return handleTextSummary(request, ask);
}
return handleVisionSummary(request, ask);
@@ -348,6 +348,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
// The replay probe finds the original event.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "evt-original",
medicationId: "med-1",
} as never);
const res = await POST(
postReq({
@@ -359,6 +360,53 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
expect(res.status).toBe(200);
expect(consumeForIntake).not.toHaveBeenCalled();
});
/**
* A token handed to an automation for one medication used to tell a name
* it may not touch (403) from one that does not exist (404), and to replay
* any of the owner's doses by key before checking its scope.
*/
function narrowToken(scopes: string[]) {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-2",
userId: "user-1",
permissions: ["medication:ingest", ...scopes],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as Awaited<ReturnType<typeof prisma.apiToken.findUnique>>);
}
it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => {
wireHappyPath();
narrowToken(["medication:med-other:ingest"]);
const known = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-known-1" }),
);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null);
const unknown = await POST(
postReq({ medicationName: "Nonexistent", idempotencyKey: "k-unknown-1" }),
);
expect(known.status).toBe(403);
expect(unknown.status).toBe(403);
expect((await unknown.json()).error).toBe((await known.json()).error);
// No replay was looked up for a medication outside the scope.
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
});
it("never replays another medication's dose to a scoped token", async () => {
wireHappyPath();
narrowToken(["medication:med-1:ingest"]);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "evt-other",
medicationId: "med-other",
takenAt: new Date(),
} as Awaited<ReturnType<typeof prisma.medicationIntakeEvent.findFirst>>);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-spent-1" }),
);
expect(res.status).toBe(409);
expect(JSON.stringify(await res.json())).not.toContain("evt-other");
});
});
describe("POST /api/ingest/medication — idempotency-key stability floor", () => {
+19 -19
View File
@@ -186,17 +186,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { medicationName, takenAt, idempotencyKey } = parsed.data;
// Idempotency check (scoped to token owner to prevent cross-user lookups)
const existing = await prisma.medicationIntakeEvent.findFirst({
where: {
idempotencyKey,
userId: apiToken.userId,
},
});
if (existing) {
return apiSuccess(existing);
}
// Find medication by name for this user
const medication = await prisma.medication.findFirst({
where: {
@@ -206,16 +195,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
if (!medication) {
return apiError("Medication not found", 404);
// A scoped token gets one answer for unknown and out-of-scope names; only a
// full-access token, which can list them anyway, learns a name is unknown.
const fullAccess = apiToken.permissions.includes("*");
if (
!medication ||
(!fullAccess &&
!apiToken.permissions.includes(`medication:${medication.id}:ingest`))
) {
return !medication && fullAccess
? apiError("Medication not found", 404)
: apiError("API endpoint for this medication is disabled", 403);
}
const medicationScope = `medication:${medication.id}:ingest`;
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes(medicationScope)
) {
return apiError("API endpoint for this medication is disabled", 403);
// Replay after the scope check, for this medication only (key is per user).
const existing = await prisma.medicationIntakeEvent.findFirst({
where: { idempotencyKey, userId: apiToken.userId },
});
if (existing) {
return existing.medicationId === medication.id
? apiSuccess(existing)
: apiError("Idempotency key already used for another medication", 409);
}
// A record-only medication takes no new dose — the same refusal every
@@ -436,3 +436,71 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () =
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
});
/**
* The secret header cannot stop a replay: a captured request carries the
* secret, and the timestamp beside it was covered by nothing. A signed
* request carries no secret and binds its timestamp to its body.
*/
describe("POST /api/internal/deploy-webhook (signed requests)", () => {
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
afterEach(() => {
if (ORIGINAL_REQUIRE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
}
});
async function signed(
body: string,
opts: { ts?: string; sign?: string; secret?: string } = {},
): Promise<NextRequest> {
const { createHmac } = await import("node:crypto");
const ts = opts.ts ?? String(Math.floor(Date.now() / 1000));
const mac = createHmac("sha256", opts.secret ?? "test-secret")
.update(`${ts}.${opts.sign ?? body}`)
.digest("hex");
return new NextRequest("https://app.example/api/internal/deploy-webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-deploy-webhook-timestamp": ts,
"x-deploy-webhook-signature": `sha256=${mac}`,
},
body,
});
}
it("accepts a signed request without the secret header", async () => {
const res = await POST(await signed('{"status":"success"}'));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledOnce();
});
it("refuses a signature over another body, an old timestamp, or another key", async () => {
for (const req of [
await signed('{"status":"failed"}', { sign: '{"status":"success"}' }),
await signed('{"status":"success"}', {
ts: String(Math.floor(Date.now() / 1000) - 600),
}),
await signed('{"status":"success"}', { secret: "other-secret" }),
]) {
expect((await POST(req)).status).toBe(401);
}
expect(auditLog).not.toHaveBeenCalled();
});
it("in strict mode accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
const unsigned = jsonRequest(
{ status: "success" },
{
"x-deploy-webhook-secret": "test-secret",
"x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)),
},
);
expect((await POST(unsigned)).status).toBe(401);
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
});
});
+100 -41
View File
@@ -1,11 +1,16 @@
import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import {
apiError,
getClientIp,
isJsonContentType,
readBodyText,
} from "@/lib/api-response";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { prisma } from "@/lib/db";
@@ -87,23 +92,27 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
}
/**
* Replay protection (v1.16.4). A captured webhook request carries a
* static shared secret, so without a freshness check it can be replayed
* indefinitely — re-paging admins with stale failure alerts and
* polluting the audit log with forged deploy outcomes.
* Freshness and signature.
*
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
* are rejected. Two modes:
* The secret header alone cannot stop a replay: whoever holds a captured
* request holds the secret it carries, and can send a fresh request of any
* content. A timestamp beside it changed nothing, because the timestamp was
* covered by nothing and could be rewritten with the rest. Earlier versions
* called it replay protection; it was a staleness hint.
*
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
* stock notification sender does not attach one, and breaking the
* live integration would cost more than the residual replay window.
* A header that IS present but stale/invalid is always rejected, so
* a replayed capture of a timestamped request never lands.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
* mandatory. For operators whose sender (or reverse proxy) can
* attach the timestamp; closes the replay window completely.
* A sender that can compute a signature authenticates without sending the
* secret at all: `X-Deploy-Webhook-Signature: sha256=<hex>` is the
* HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the timestamp header's
* value, a dot, and the raw body, and the timestamp must lie within five
* minutes. A capture of such a request is useless after the window and
* reveals no secret.
*
* - Default: a signed request is verified as above; an unsigned one
* authenticates with the secret header, as Coolify's stock sender does,
* and a timestamp it carries must still be fresh.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: only a signed request is
* accepted. The name predates the signature; requiring a timestamp
* without one would not make a replay any harder.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
@@ -123,13 +132,7 @@ function checkTimestampFreshness(
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
const required =
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
if (raw === null || raw === "") {
if (required) return { ok: false, reason: "timestamp_missing" };
return { ok: true };
}
if (raw === null || raw === "") return { ok: true };
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
@@ -138,6 +141,44 @@ function checkTimestampFreshness(
return { ok: true };
}
function strictMode(): boolean {
return (
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1"
);
}
/**
* A signed request: the HMAC of `timestamp.body` under the shared secret,
* with a fresh timestamp. Null when the request carries no signature.
*/
function verifySignature(
request: NextRequest,
rawBody: string,
now: number = Date.now(),
): { ok: boolean; reason?: string } | null {
const header = request.headers.get("x-deploy-webhook-signature");
if (header === null) return null;
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) return { ok: false, reason: "secret_unset" };
const timestamp = request.headers.get("x-deploy-webhook-timestamp");
if (!timestamp) return { ok: false, reason: "timestamp_missing" };
const ts = parseTimestamp(timestamp);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
return { ok: false, reason: "timestamp_outside_window" };
}
const mac = createHmac("sha256", expected)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(`sha256=${mac}`, "utf8");
const b = Buffer.from(header.trim(), "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: "signature_mismatch" };
}
return { ok: true };
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
@@ -238,27 +279,45 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
if (!isJsonContentType(request.headers.get("content-type"))) {
return apiError("Content-Type must be application/json", 415);
}
// The raw body, because a signature covers the exact bytes sent.
const read = await readBodyText(request, 64 * 1024).catch(() => null);
if (!read) return apiError("Invalid request body", 400);
if (read.tooLarge) return apiError("Request body exceeds 65536 bytes", 413);
const signed = verifySignature(request, read.text);
if (signed) {
if (!signed.ok) {
annotate({ meta: { deploy_webhook_auth_reason: signed.reason } });
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
annotate({ meta: { deploy_webhook_auth: "signature" } });
} else {
if (strictMode() || !hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy, and a replayed-but-stale request
// from a captured legitimate call is rejected here.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
let payload: CoolifyDeployPayload;
try {
payload = JSON.parse(read.text) as CoolifyDeployPayload;
} catch {
return apiError("Invalid JSON body", 400);
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
await safeJson<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const event = normalizePayload(payload);
annotate({
+2 -1
View File
@@ -48,6 +48,7 @@ import {
apiValidationError,
safeJson,
sanitiseZodIssues,
isJsonContentType,
} from "@/lib/api-response";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
@@ -91,7 +92,7 @@ export const POST = apiHandler(async (request: Request) => {
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
// multipart body is the native-vision image/PDF mode.
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (isJsonContentType(contentType)) {
return handleTextExtract(request, user.id);
}
return handleVisionExtract(request, user.id);
@@ -1,6 +1,7 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
import { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
@@ -496,3 +497,58 @@ describe("authorize CSP — the consent form may lead only to the validated redi
expect(csp(unavailable)).toContain("default-src 'none'");
});
});
/**
* Dynamic registration lets anyone register "Claude" with a callback on a
* host of their own and send a signed-in person the authorize link. The
* consent page used to show the borrowed name and "Unverified application",
* never where the code would go.
*/
describe("GET /authorize — where the access goes (M3)", () => {
it("names the redirect host for a self-registered client, on the page and on Allow", async () => {
const phish = registerDcrClient({
clientName: "Claude",
redirectUris: ["https://collector.example.net/cb"],
});
signedIn();
const body = await (
await GET(
new NextRequest(
authorizeUrl({
client_id: phish.clientId,
redirect_uri: "https://collector.example.net/cb",
}),
),
)
).text();
expect(body).toMatch(/Check where this access goes/);
expect(body).toMatch(
/Access goes to:<\/strong> <code>collector\.example\.net<\/code>/,
);
expect(body).toMatch(
/value="allow">Allow access for collector\.example\.net</,
);
// The warning comes before the borrowed name.
expect(body.indexOf("Check where")).toBeLessThan(body.indexOf("Claude"));
});
it("says a loopback redirect stays on this device", async () => {
const desktop = registerDcrClient({
clientName: "Desktop client",
redirectUris: ["http://127.0.0.1:4000/callback"],
});
signedIn();
const body = await (
await GET(
new NextRequest(
authorizeUrl({
client_id: desktop.clientId,
redirect_uri: "http://127.0.0.1:4000/callback",
}),
),
)
).text();
expect(body).toMatch(/an application running on this device/);
expect(body).not.toMatch(/Check where this access goes/);
});
});
+53 -1
View File
@@ -420,6 +420,7 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
v.clientSource === "cimd"
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
: `<p><strong>Unverified application</strong> (dynamically registered, the name above is self-reported and not verified).</p>`;
const destination = consentDestination(v.redirectUri, v.clientSource);
// Plain-language access summary. When write is granted, say so explicitly —
// the app will be able to READ and LOG/WRITE health data, not just read.
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
@@ -436,8 +437,10 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
html(
`<main>
<h1>Authorize access</h1>
${destination.warning}
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
${provenance}
${destination.line}
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
${accessSummary}
${readSummary}
@@ -450,7 +453,7 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
${hidden("scope", v.scope)}
${hidden("resource", v.resource)}
${v.state !== undefined ? hidden("state", v.state) : ""}
<button type="submit" name="decision" value="allow">Allow</button>
<button type="submit" name="decision" value="allow">${destination.allowLabel}</button>
<button type="submit" name="decision" value="deny">Deny</button>
</form>
</main>`,
@@ -460,6 +463,55 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
});
}
/**
* Where an Allow sends the access, said on the consent page in words a person
* checks: the redirect URI's host, or "an application on this device" for a
* loopback redirect.
*
* Dynamic registration is open by specification and the client names itself,
* so anyone can register "Claude" with a redirect to a host of their own and
* send a signed-in person the authorize link; the page used to show the
* borrowed name and never the host the code would go to. The host is now on
* every consent page and on the Allow button. For an unverified client whose
* redirect leaves this device, a warning leads the page.
*
* Registration itself stays open: the hosted assistants register this way
* with their own https callback, so closing it or allowlisting hosts would
* break them on every instance, and a switch left at its default would
* protect nobody. Naming where the access goes is what a phished person can
* act on.
*/
function consentDestination(
redirectUri: string,
clientSource: string,
): { warning: string; line: string; allowLabel: string } {
let url: URL;
try {
url = new URL(redirectUri);
} catch {
return { warning: "", line: "", allowLabel: "Allow" };
}
const loopback =
url.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
if (loopback) {
return {
warning: "",
line: `<p><strong>Access goes to:</strong> an application running on this device (<code>${htmlEscape(url.host)}</code>).</p>`,
allowLabel: "Allow",
};
}
const host = htmlEscape(url.host);
return {
warning:
clientSource === "cimd"
? ""
: `<p><strong>Check where this access goes before you allow it.</strong> Whoever runs <code>${host}</code> will be able to use it. Allow only if you started this connection from <code>${host}</code> yourself; the application's name below is its own claim.</p>`,
line: `<p><strong>Access goes to:</strong> <code>${host}</code></p>`,
allowLabel: `Allow access for ${host}`,
};
}
async function authorizePost(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
if (!(await surfaceAvailable())) {
@@ -35,6 +35,7 @@ import {
apiSuccess,
getClientIp,
readBodyText,
isJsonContentType,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -127,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
const isJson = contentType.includes("application/json");
const isJson = isJsonContentType(contentType);
const isText =
contentType.includes("text/csv") || contentType.includes("text/plain");
if (!isJson && !isText) {
@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -29,6 +30,7 @@ import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
import { requireAuth } from "@/lib/api-handler";
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
@@ -40,6 +42,9 @@ function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as Awaited<ReturnType<typeof requireAuth>>);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 19,
@@ -58,6 +63,20 @@ beforeEach(() => {
});
describe("POST /api/monitoring/glitchtip", () => {
it("relays nothing for a caller without a session", async () => {
vi.mocked(requireAuth).mockRejectedValue(new Error("Unauthorized"));
await expect(POST(jsonReq({ message: "injected" }))).rejects.toThrow();
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
});
it("limits the relay per account", async () => {
await POST(jsonReq({ message: "boom" }));
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
"glitchtip:user:user-1",
);
});
it("returns 429 when rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
@@ -169,20 +188,9 @@ describe("POST /api/monitoring/glitchtip", () => {
expect(res.status).toBe(502);
});
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": "10.0.0.42",
},
body: JSON.stringify({ message: "boom" }),
});
await POST(r);
it("keeps the documented 20 per minute window", async () => {
await POST(jsonReq({ message: "boom" }));
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
// First arg is the bucket key — must include the resolved client IP.
expect(callArgs?.[0]).toContain("10.0.0.42");
// 20 requests / 60 000 ms — guard the documented sliding window.
expect(callArgs?.[1]).toBe(20);
expect(callArgs?.[2]).toBe(60_000);
});
+9 -4
View File
@@ -4,13 +4,12 @@ import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
import { apiHandler } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
const clientErrorSchema = z
@@ -24,11 +23,17 @@ const clientErrorSchema = z
})
.strict();
/**
* The browser's error reporter relays here into the operator's GlitchTip.
* It needs a signed-in session: the relay used to be anonymous, so anyone
* could write events into the incident list, limited only per address. A
* page that fails before sign-in goes unreported, which is the price.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "monitoring.glitchtip.report" } });
const ip = getClientIp(request);
const rl = await checkRateLimit(`glitchtip:${ip}`, 20, 60 * 1000);
const { user } = await requireAuth();
const rl = await checkRateLimit(`glitchtip:user:${user.id}`, 20, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getGlitchtipSettings();
@@ -1,8 +1,9 @@
/**
* POST /api/notifications/action/{token} — a quick answer from a
* notification. Pins the order of the gates and every refusal class: rate
* limit before the token is read, then signature, expiry, account, module,
* and the single-use write.
* notification. Pins the order of the gates and every refusal class: the
* signature (an HMAC, no database read), then the rate limit (per account for
* a genuine token, per address in a bucket of its own for any other), expiry,
* account, module, and the single-use write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
@@ -18,6 +19,7 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
@@ -31,7 +33,7 @@ vi.mock("@/lib/db-compat", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isModuleEnabled } from "@/lib/modules/gate";
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
import {
@@ -65,6 +67,12 @@ beforeEach(() => {
resetAt: new Date(),
ip: "203.0.113.5",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now(),
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
timezone: "Europe/Berlin",
@@ -98,17 +106,39 @@ describe("POST /api/notifications/action/{token}", () => {
});
});
it("is rate-limited before the token is read", async () => {
it("limits a genuine token per account, before any database read", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 20,
remaining: 0,
resetAt: Date.now(),
});
const res = await call(token());
expect(res.status).toBe(429);
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
"notification-action:user:user-1",
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("charges junk to a bucket of its own, so it cannot hold genuine answers back", async () => {
// The junk bucket is spent, as it is for everyone when the address
// cannot be read and all junk lands in one bucket.
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(),
ip: "203.0.113.5",
ip: null,
} as never);
const res = await call(token());
expect(res.status).toBe(429);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(logTelegramMood).not.toHaveBeenCalled();
const junk = await call("hla_not-a-token");
expect(junk.status).toBe(429);
expect(vi.mocked(checkAuthSurfaceRateLimit).mock.calls[0]![1]).toBe(
"notification-action-invalid",
);
const genuine = await call(token());
expect(genuine.status).toBe(200);
});
it("refuses a tampered token with 401 and writes nothing", async () => {
@@ -42,7 +42,11 @@ import {
quickActionExternalId,
verifyActionToken,
} from "@/lib/notifications/action-token";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
@@ -64,12 +68,27 @@ export const POST = apiHandler(
async (request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "notification.action.invoke" } });
const rl = await checkAuthSurfaceRateLimit(
request,
"notification-action",
RATE_LIMIT,
RATE_WINDOW_MS,
);
// The token is verified first: it is an HMAC, so checking it costs one
// hash and no database read. A forged or stale token is limited per
// address in a bucket of its own, and a genuine one per account. The one
// shared bucket this used to charge first, keyed on the address, became
// a single bucket for everyone whenever the address could not be read,
// and a couple of junk requests a minute switched off every person's
// quick answers.
const { token } = await context.params;
const verdict = verifyActionToken(token);
const rl = verdict.ok
? await checkRateLimit(
`notification-action:user:${verdict.userId}`,
RATE_LIMIT,
RATE_WINDOW_MS,
)
: await checkAuthSurfaceRateLimit(
request,
"notification-action-invalid",
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "notification.action.refused" },
@@ -82,8 +101,6 @@ export const POST = apiHandler(
return response;
}
const { token } = await context.params;
const verdict = verifyActionToken(token);
if (!verdict.ok) {
annotate({
action: { name: "notification.action.refused" },