Merge the release with the security review fixes into the data-safety review fixes

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 18:36:54 +01:00
commit bcb31c80db
98 files changed
+3166 -608

No files matched your search

@@ -20,7 +20,6 @@ vi.mock("@/lib/rate-limit", () => ({
const validateSessionWithCreatedAt = vi.fn();
vi.mock("@/lib/auth/session", () => ({
validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v),
SESSION_COOKIE_NAME: "healthlog_session",
}));
// Keep the real shared core (buildHandoffCallbackUrl is used by
+6 -12
View File
@@ -29,11 +29,11 @@ import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { validateSessionWithCreatedAt } from "@/lib/auth/session";
import {
validateSessionWithCreatedAt,
SESSION_COOKIE_NAME,
} from "@/lib/auth/session";
expireSessionCookies,
readSessionCookie,
} from "@/lib/auth/session-cookie";
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
import {
NATIVE_HANDOFF_STATE_COOKIE,
@@ -95,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
// (row + expiry), never "a cookie is present". Projects `createdAt` so the
// freshness comparison below is structurally always available.
const authed = await validateSessionWithCreatedAt(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (!authed) {
annotate({ meta: { reason: "no_session" } });
@@ -140,13 +140,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
await prisma.session
.delete({ where: { id: authed.session.id } })
.catch(() => {});
response.cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: 0,
path: "/",
});
expireSessionCookies(response.cookies);
return response;
});
@@ -42,7 +42,6 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn(),
validateSessionFromCookieValue: vi.fn(),
SESSION_COOKIE_NAME: "healthlog_session",
}));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/auth/mfa/challenge", () => ({
+4 -6
View File
@@ -3,10 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import {
validateSessionFromCookieValue,
SESSION_COOKIE_NAME,
} from "@/lib/auth/session";
import { validateSessionFromCookieValue } from "@/lib/auth/session";
import { readSessionCookie } from "@/lib/auth/session-cookie";
import {
discoverOidcMetadata,
exchangeCodeForTokens,
@@ -225,7 +223,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
@@ -258,7 +256,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
: null;
if (isClaim) {
const liveSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (liveSession) {
annotate({ meta: { reason: "claim_signed_in" } });
@@ -444,12 +444,18 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () =
*/
describe("POST /api/internal/deploy-webhook (signed requests)", () => {
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
const ORIGINAL_SIGNATURE = process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
afterEach(() => {
if (ORIGINAL_REQUIRE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
}
if (ORIGINAL_SIGNATURE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = ORIGINAL_SIGNATURE;
}
});
async function signed(
@@ -491,8 +497,8 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
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";
it("with DEPLOY_WEBHOOK_REQUIRE_SIGNATURE accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = "true";
const unsigned = jsonRequest(
{ status: "success" },
{
@@ -503,4 +509,24 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
expect((await POST(unsigned)).status).toBe(401);
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
});
it("keeps DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP meaning the secret plus a fresh timestamp", async () => {
// What the flag meant before 1.46, and what an operator who set it with
// a sender that timestamps but cannot sign still gets.
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
const fresh = jsonRequest(
{ status: "success" },
{
"x-deploy-webhook-secret": "test-secret",
"x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)),
},
);
expect((await POST(fresh)).status).toBe(200);
const bare = jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
);
expect((await POST(bare)).status).toBe(401);
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
});
});
+23 -10
View File
@@ -110,9 +110,15 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
* - 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.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: what it has always meant, the
* secret header plus a fresh timestamp; an unsigned request without one
* is refused. It keeps a stale capture out, not a fresh forgery by
* someone who saw the secret. A signed request is accepted as above.
* - `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true`: only a signed request is
* accepted; the secret header alone is refused. This is the setting
* that stops a replay. It has its own name so that an operator who set
* the timestamp flag, with a sender that adds a timestamp but cannot
* sign, keeps the behaviour they asked for.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
@@ -132,7 +138,11 @@ function checkTimestampFreshness(
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
if (raw === null || raw === "") return { ok: true };
if (raw === null || raw === "") {
return flagSet("DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP")
? { ok: false, reason: "timestamp_missing" }
: { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
@@ -141,11 +151,11 @@ function checkTimestampFreshness(
return { ok: true };
}
function strictMode(): boolean {
return (
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1"
);
function flagSet(
name: "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP" | "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE",
): boolean {
const value = process.env[name]?.trim().toLowerCase();
return value === "true" || value === "1";
}
/**
@@ -296,7 +306,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.setAuth({ auth_method: "webhook_secret" });
annotate({ meta: { deploy_webhook_auth: "signature" } });
} else {
if (strictMode() || !hasValidSecret(request)) {
if (
flagSet("DEPLOY_WEBHOOK_REQUIRE_SIGNATURE") ||
!hasValidSecret(request)
) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns