mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Cap how many live measurement tokens an account may hold
The mint's rate limit bounds a burst and does nothing about accumulation: a stuck automation retrying politely inside its 10-per-minute allowance still leaves several hundred live credentials behind in an afternoon, each one to be revoked by hand. Count live rows before the create and refuse at ten. Live, not minted-ever, so revoking frees a slot and an expired token stops occupying one — the ceiling bounds what currently exists rather than what the account has ever done. Scoped to measurements:write for a reason worth stating: signing in mints a wildcard access token per session and those accumulate on their own, so counting every row would let ordinary browser use exhaust the budget and then refuse a mint for a reason invisible to the person hitting it. 409 rather than 429. This is a conflict with the account's current state, not a rate: it does not clear by waiting, it clears by revoking, and a 429 would tell the caller the opposite of what is true. Placed after validation, so a malformed request hears about the malformation rather than the ceiling, and before the create, so hitting the limit costs no credential. Ten is set to catch a loop rather than to ration — a household running Home Assistant, a scale bridge and a couple of scripts sits at three or four, and it is not a security boundary since the holder can revoke and mint freely. Tests pin the boundary, the query shape and the ordering, and were mutation-checked: turning >= into > fails the ceiling case, and dropping the scope filter fails the query-shape case. The integration leg seeds a wildcard and an expired row as decoys, then revokes one live token and asserts exactly one slot opens — the leg that fails if the count is of mints rather than of tokens alive.
This commit is contained in:
1 parent
6db18d0b71
commit
371c0c49f1
4 files changed
+190
-1
No files matched your search
@@ -37,12 +37,17 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { apiToken: { count: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const USER = { id: "user-1", role: "USER" as const };
|
||||
@@ -62,6 +67,8 @@ beforeEach(() => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
// Below the ceiling by default; the cases that care set their own count.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: EXPIRES,
|
||||
@@ -145,6 +152,55 @@ describe("when it refuses", () => {
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("409s at the live-token ceiling, without minting", async () => {
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
|
||||
// 409, not 429: a conflict with the account's current state, whose remedy
|
||||
// is revoking rather than waiting. A 429 would say the opposite.
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta?.errorCode).toBe(
|
||||
"tokens.measurements.ceiling_reached",
|
||||
);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints on the last free slot", async () => {
|
||||
// The off-by-one that would make the ceiling nine. Worth pinning, because
|
||||
// `>=` versus `>` is invisible in review and only one of them is right.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(9 as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("counts only live tokens carrying this scope", async () => {
|
||||
// The whole shape of the query, asserted rather than assumed. Counting
|
||||
// every row would let the `["*"]` tokens a login mints consume the budget
|
||||
// and refuse a mint for a reason the person cannot see; counting revoked
|
||||
// or expired rows would mean revoking never frees a slot.
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
|
||||
const where = vi.mocked(prisma.apiToken.count).mock.calls[0][0]?.where;
|
||||
expect(where).toMatchObject({
|
||||
userId: USER.id,
|
||||
revoked: false,
|
||||
permissions: { has: MEASUREMENTS_WRITE_SCOPE },
|
||||
});
|
||||
expect(where?.OR).toEqual([
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: expect.any(Date) } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks the ceiling only after the body validates", async () => {
|
||||
// A malformed request should hear about the malformation, not the
|
||||
// ceiling — and should not spend a query establishing it.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
|
||||
const res = await POST(req({}) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.apiToken.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a scope the body tries to smuggle in", async () => {
|
||||
// There is no `scope` field to validate, so an unknown key is simply not
|
||||
// read — the permission array is a literal either way. Asserted because
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -62,6 +63,23 @@ const DEFAULT_EXPIRY_DAYS = 365;
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* How many ingest tokens an account may hold at once.
|
||||
*
|
||||
* The rate limit above and this are different guards and neither substitutes
|
||||
* for the other. The bucket bounds a burst; it does nothing about
|
||||
* accumulation, and a stuck automation retrying politely inside its allowance
|
||||
* still reaches several hundred live credentials in an afternoon. This bounds
|
||||
* the standing set, so a runaway script meets a refusal rather than leaving a
|
||||
* thousand rows for somebody to revoke by hand.
|
||||
*
|
||||
* Ten is meant to be a number nobody legitimately reaches: a household running
|
||||
* Home Assistant, a scale bridge and a couple of scripts sits at three or four.
|
||||
* It is not a security boundary — the person can revoke and mint freely — so
|
||||
* it is set to catch a loop, not to ration.
|
||||
*/
|
||||
const MAX_LIVE_TOKENS = 10;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only, and the reason is lifetime rather than reach.
|
||||
//
|
||||
@@ -102,6 +120,40 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Counted LIVE, not minted-ever, so revoking frees a slot and an expired
|
||||
// token stops occupying one — the ceiling bounds what currently exists
|
||||
// rather than what the account has ever done. Scoped to this grant for the
|
||||
// same reason: a login mints a `["*"]` access token per session and those
|
||||
// accumulate on their own, so counting every row would let ordinary browser
|
||||
// use exhaust the budget and then refuse a mint for a reason invisible to
|
||||
// the person hitting it.
|
||||
//
|
||||
// Placed after validation and before the create: a request that is going to
|
||||
// be refused should not cost a credential, and one that is malformed should
|
||||
// hear about the malformation rather than the ceiling.
|
||||
const live = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
revoked: false,
|
||||
permissions: { has: MEASUREMENTS_WRITE_SCOPE },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
});
|
||||
if (live >= MAX_LIVE_TOKENS) {
|
||||
annotate({
|
||||
action: { name: "tokens.measurements.create" },
|
||||
meta: { outcome: "ceiling_reached", live_token_count: live },
|
||||
});
|
||||
// 409 rather than 429: this is a conflict with the account's current
|
||||
// state, not a rate, and the remedy is to revoke one rather than to wait.
|
||||
// A 429 would tell the caller the opposite of what is true.
|
||||
return apiError(
|
||||
`You already have ${MAX_LIVE_TOKENS} measurement tokens. Revoke one before creating another.`,
|
||||
409,
|
||||
{ errorCode: "tokens.measurements.ceiling_reached" },
|
||||
);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
|
||||
@@ -1620,7 +1620,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other.",
|
||||
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1671,6 +1671,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The account already holds 10 live measurement tokens (`meta.errorCode` = `tokens.measurements.ceiling_reached`). Nothing was minted. Distinct from the 429 above: that one is a rate and clears by waiting, this one is a conflict with the account's current state and clears by revoking a token at `DELETE /api/tokens/{id}`. Only LIVE tokens count — revoked and expired rows do not occupy a slot — and only those carrying `measurements:write`, so the wildcard tokens a sign-in mints never consume the budget.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -692,6 +692,82 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The live-token ceiling, on a cookie session because that is the only
|
||||
* credential the mint admits.
|
||||
*
|
||||
* Here rather than in a file of its own for one reason: the assertion that
|
||||
* needs a real database is `permissions: { has: … }` against a Postgres
|
||||
* `text[]`, and the harness for that already exists in this file. A mock
|
||||
* can confirm the query was built; only Postgres can confirm it selects the
|
||||
* rows it means to.
|
||||
*/
|
||||
it("refuses a mint once the account holds the ceiling, and revoking frees a slot", async () => {
|
||||
const CEILING = 10;
|
||||
const now = Date.now();
|
||||
await getPrismaClient().apiToken.createMany({
|
||||
data: Array.from({ length: CEILING }, (_, i) => ({
|
||||
userId: USER_ID,
|
||||
name: `bridge ${i}`,
|
||||
tokenHash: hashToken(`hlk_ceiling_${i}_${"0".repeat(40)}`),
|
||||
permissions: ["measurements:write"],
|
||||
expiresAt: new Date(now + 86_400_000),
|
||||
})),
|
||||
});
|
||||
// Neither of these may occupy a slot: one is a different scope, the other
|
||||
// has already expired. Seeded so a query that counted either would push
|
||||
// the live figure past the ceiling and pass this test for the wrong
|
||||
// reason on the revoke leg below.
|
||||
await getPrismaClient().apiToken.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: USER_ID,
|
||||
name: "a browser session",
|
||||
tokenHash: hashToken(`hlk_wild_${"0".repeat(44)}`),
|
||||
permissions: ["*"],
|
||||
expiresAt: new Date(now + 86_400_000),
|
||||
},
|
||||
{
|
||||
userId: USER_ID,
|
||||
name: "long expired",
|
||||
tokenHash: hashToken(`hlk_expired_${"0".repeat(40)}`),
|
||||
permissions: ["measurements:write"],
|
||||
expiresAt: new Date(now - 86_400_000),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await useCookieSession();
|
||||
const { POST } = await import("@/app/api/tokens/measurements/route");
|
||||
const mint = () =>
|
||||
POST(
|
||||
new NextRequest("https://health.example/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "one too many" }),
|
||||
} as never),
|
||||
);
|
||||
|
||||
const refused = await mint();
|
||||
expect(refused.status).toBe(409);
|
||||
expect((await refused.json()).meta?.errorCode).toBe(
|
||||
"tokens.measurements.ceiling_reached",
|
||||
);
|
||||
|
||||
// Revoking one live row — not the expired or wildcard decoys — must open
|
||||
// exactly one slot. This is the leg that fails if the count is of mints
|
||||
// ever made rather than of tokens currently alive.
|
||||
const victim = await getPrismaClient().apiToken.findFirstOrThrow({
|
||||
where: { name: "bridge 0" },
|
||||
});
|
||||
await getPrismaClient().apiToken.update({
|
||||
where: { id: victim.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
expect((await mint()).status).toBe(201);
|
||||
});
|
||||
|
||||
it("cannot reach the medication ingest surface", async () => {
|
||||
await armToken(["measurements:write"], "mwrite8");
|
||||
const { POST } = await import("@/app/api/ingest/medication/route");
|
||||
|
||||
Reference in new issue
Block a user