chore(release): v1.28.48 — security follow-ups

From the campaign's security re-verification pass (posture otherwise held
across the 10-release week — no Critical/High):

- MEDIUM: the OIDC auto-provision branch re-introduced the first-admin race
  the password-register route fixed in v1.28.42 — count-then-create with no
  lock, so two concurrent OIDC first-logins (or an OIDC first-login racing a
  password first-registration) in the empty-DB window could both mint ADMIN.
  Now wraps count+role+create in a $transaction taking the SAME advisory lock
  key ('register:first-admin') and re-counts inside, so the two paths
  serialise; every provisioned field preserved.
- LOW: the nutrients batch catch echoed the raw exception message into the
  client-facing reason (violating its closed reason-set). Now returns the
  fixed 'upsert_failed' member and logs the detail server-side only.

No migration (advisory lock is runtime).
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 08:03:12 +02:00
1 parent f3a3b8f3a7
commit a9a318d4a4
8 files changed
+163 -20

No files matched your search

@@ -333,9 +333,12 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
);
});
it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
it("a thrown upsert marks only that entry skipped upsert_failed without leaking the raw error", async () => {
// The exception message must never reach the client — the per-entry
// `reason` is a closed set. A raw-message echo was a v1.28.48 fix.
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(prisma.nutrientIntakeDay.upsert)
.mockRejectedValueOnce(new Error("boom"))
.mockRejectedValueOnce(new Error("boom: sensitive db detail"))
.mockResolvedValueOnce({} as never);
const res = await POST(
@@ -348,7 +351,13 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("upsert_failed");
expect(body.data.skipped[0].reason).toBe("upsert_failed");
// The raw exception text is logged server-side only, never echoed.
expect(JSON.stringify(body)).not.toContain("sensitive db detail");
expect(spy).toHaveBeenCalled();
expect(body.data.entries[1].status).toBe("inserted");
expect(body.data.inserted).toBe(1);
spy.mockRestore();
});
});
+6 -2
View File
@@ -208,8 +208,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
results.push({ index: i, status: "inserted" });
}
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
// The client-facing `reason` is a closed set (see the file header) —
// never echo raw exception text into it. Log the real error
// server-side only (SWC keeps `console.error` in prod) and return the
// fixed `upsert_failed` member.
console.error("[nutrient-batch] upsert failed", err);
const reason = "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}