mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge the release with the security fixes into the backup health work
This commit is contained in:
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 ?? {};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user