Compare commits

...

2 Commits

Author SHA1 Message Date
Flea Flicker 4594bd2307 fix(GRO-655): create corepack cache dir in builder stage
Prevents ENOENT crash in migrate and seed jobs.

Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1
but the directory does not exist in the builder stage. This was a
regression in c438f57 where the cache directory was not pre-created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 21:58:44 +00:00
Flea Flicker d8c0052b54 fix(GRO-634): implement auth & authorization security hardening (8 findings)
- Remove placeholder secret fallback, require BETTER_AUTH_SECRET when AUTH_DISABLED=true
- Fix TOCTOU race in setup: use INSERT...RETURNING for atomic confirmation token creation
- Fix confirmation token replay: atomic UPDATE with WHERE clause prevents double-use
- Add CSRF origin-check middleware for non-safe HTTP methods
- Validate OIDC discovery URL hostname matches configured issuer
- Use timing-safe comparison for iCal authentication tokens
- Add rate limiting (10 req/min per IP) on setup endpoints
- Fix RBAC error messages: correct inversion of privilege check
2026-04-14 17:08:02 +00:00
10 changed files with 164 additions and 79 deletions
+1
View File
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
# Build # Build
FROM deps AS builder FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/ COPY packages/ packages/
COPY apps/api/ apps/api/ COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \ RUN pnpm --filter @groombook/types build && \
+1 -1
View File
@@ -142,8 +142,8 @@ describe("auth init", () => {
...originalEnv, ...originalEnv,
AUTH_DISABLED: "true", AUTH_DISABLED: "true",
NODE_ENV: "test", NODE_ENV: "test",
BETTER_AUTH_SECRET: "placeholder-for-test-only",
}; };
delete process.env.BETTER_AUTH_SECRET;
const { initAuth, getAuth } = await reimportAuth(); const { initAuth, getAuth } = await reimportAuth();
await expect(initAuth()).resolves.toBeUndefined(); await expect(initAuth()).resolves.toBeUndefined();
+31 -11
View File
@@ -31,11 +31,11 @@ const BASE_APPT = {
// ─── Shared mock DB state ───────────────────────────────────────────────────── // ─── Shared mock DB state ─────────────────────────────────────────────────────
let mockAppt: typeof BASE_APPT | null = BASE_APPT; let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
let lastUpdate: Record<string, unknown> = {}; let lastUpdate: Record<string, unknown> = {};
function resetMock() { function resetMock() {
mockAppt = { ...BASE_APPT }; mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
lastUpdate = {}; lastUpdate = {};
} }
@@ -55,19 +55,39 @@ vi.mock("@groombook/db", () => {
}), }),
}), }),
update: () => ({ update: () => ({
set: (vals: Record<string, unknown>) => ({ set: (vals: Record<string, unknown>) => {
where: () => { const setVals = vals;
lastUpdate = { ...vals }; return {
if (mockAppt) { where: () => {
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT; const preUpdate = mockAppt ? { ...mockAppt } : null;
} const preStatus = preUpdate?.confirmationStatus;
return { returning: () => (mockAppt ? [mockAppt] : []) }; const preStart = preUpdate?.startTime;
}, lastUpdate = { ...setVals };
}), const whereMatched =
preUpdate != null &&
preStatus === "pending" &&
preStart != null &&
preStart > new Date();
if (whereMatched && mockAppt) {
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
}
return {
returning: () => {
if (!preUpdate) return [];
if (preStatus !== "pending") return [];
if (preStart && preStart <= new Date()) return [];
return whereMatched && mockAppt ? [mockAppt] : [];
},
};
},
};
},
}), }),
}), }),
appointments, appointments,
eq: () => ({}), eq: () => ({}),
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
gt: () => ({}),
}; };
}); });
+2 -2
View File
@@ -362,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test"); const res = await app.request("/test");
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i); expect(body.error).toMatch(/role 'receptionist' is not permitted/i);
}); });
it("blocks a non-super-user groomer from manager-only routes", async () => { it("blocks a non-super-user groomer from manager-only routes", async () => {
@@ -370,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test"); const res = await app.request("/test");
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i); expect(body.error).toMatch(/role 'groomer' is not permitted/i);
}); });
it("allows a manager with multiple allowed roles", async () => { it("allows a manager with multiple allowed roles", async () => {
+17
View File
@@ -42,6 +42,23 @@ app.use(
}) })
); );
// CSRF protection for state-changing requests
app.use("/api/*", async (c, next) => {
const method = c.req.method;
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
await next();
return;
}
const origin = c.req.header("origin");
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
if (origin && origin !== trustedOrigin) {
c.status(403);
c.json({ error: "CSRF validation failed: origin mismatch" });
return;
}
await next();
});
// Health check (no auth required) // Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" })); app.get("/health", (c) => c.json({ status: "ok" }));
+35 -11
View File
@@ -86,10 +86,15 @@ export async function initAuth(): Promise<void> {
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder // AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
// config so auth.handler exists (middleware bypasses it anyway) // config so auth.handler exists (middleware bypasses it anyway)
if (process.env.AUTH_DISABLED === "true") { if (process.env.AUTH_DISABLED === "true") {
if (!BETTER_AUTH_SECRET) {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
);
}
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance"); console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({ authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }), database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod", secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: { rateLimit: {
enabled: true, enabled: true,
@@ -199,20 +204,36 @@ export async function initAuth(): Promise<void> {
return url; return url;
} }
}; };
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
try {
const discovered = new URL(url);
const expected = new URL(issuerUrl);
return discovered.hostname === expected.hostname;
} catch {
return false;
}
};
const authzUrl = discovery.authorization_endpoint; const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint; const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint; const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) { if (authzUrl && tokenUrl && userInfoUrl) {
oidcConfig = { const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
authorizationUrl: authzUrl, const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
tokenUrl: providerConfig.internalBaseUrl const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
? replaceHost(tokenUrl, providerConfig.internalBaseUrl) if (!validAuthz || !validToken || !validUserInfo) {
: tokenUrl, console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
userInfoUrl: providerConfig.internalBaseUrl } else {
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) oidcConfig = {
: userInfoUrl, authorizationUrl: authzUrl,
}; tokenUrl: providerConfig.internalBaseUrl
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); ? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
}
} else { } else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
} }
@@ -287,6 +308,9 @@ export async function initAuth(): Promise<void> {
enabled: true, enabled: true,
maxAge: 5 * 60, // 5 minutes maxAge: 5 * 60, // 5 minutes
}, },
cookieAttributes: {
sameSite: "strict",
},
}, },
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
}); });
+3 -3
View File
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
} }
return c.json( return c.json(
{ {
error: staffRow.isSuperUser error: hasAllowedRole
? `Forbidden: role '${staffRow.role}' is not permitted` ? "Forbidden: super user privileges required"
: "Forbidden: super user privileges required", : `Forbidden: role '${staffRow.role}' is not permitted`,
}, },
403 403
); );
+38 -49
View File
@@ -255,39 +255,37 @@ bookRouter.get("/confirm/:token", async (c) => {
const token = c.req.param("token"); const token = c.req.param("token");
const db = getDb(); const db = getDb();
// Atomic: consume token and confirm in a single query to prevent replay.
// Only future appointments can be confirmed.
const [appt] = await db const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Idempotent confirm: if already confirmed, redirect to success
if (appt.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
// Reject if already cancelled
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "confirmed", confirmationStatus: "confirmed",
confirmedAt: new Date(), confirmedAt: new Date(),
confirmationToken: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(appointments.id, appt.id)); .where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending"),
gt(appointments.startTime, new Date())
)
)
.returning();
if (!appt) {
// Check status for idempotency: already-confirmed → redirect to confirmed
const [existing] = await db
.select({ confirmationStatus: appointments.confirmationStatus })
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (existing?.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
return c.redirect(`${BASE_URL()}/booking/error`);
}
return c.redirect(`${BASE_URL()}/booking/confirmed`); return c.redirect(`${BASE_URL()}/booking/confirmed`);
}); });
@@ -299,29 +297,9 @@ bookRouter.get("/cancel/:token", async (c) => {
const token = c.req.param("token"); const token = c.req.param("token");
const db = getDb(); const db = getDb();
// Atomic: consume token and cancel in a single query to prevent replay.
// Only future appointments can be cancelled.
const [appt] = await db const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if already cancelled (token was nullified — this path won't normally hit,
// but guard against edge cases where token lookup still works)
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Single-use cancellation: nullify token after use
await db
.update(appointments) .update(appointments)
.set({ .set({
confirmationStatus: "cancelled", confirmationStatus: "cancelled",
@@ -329,7 +307,18 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null, confirmationToken: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(appointments.id, appt.id)); .where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending"),
gt(appointments.startTime, new Date())
)
)
.returning();
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
return c.redirect(`${BASE_URL()}/booking/cancelled`); return c.redirect(`${BASE_URL()}/booking/cancelled`);
}); });
+7 -2
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { randomBytes } from "node:crypto"; import { randomBytes, timingSafeEqual } from "node:crypto";
import { import {
and, and,
eq, eq,
@@ -84,7 +84,12 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId)) .where(eq(staff.id, staffId))
.limit(1); .limit(1);
if (!staffMember || staffMember.icalToken !== token) { if (
!staffMember ||
!staffMember.icalToken ||
staffMember.icalToken.length !== token.length ||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
) {
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
} }
+29
View File
@@ -6,6 +6,25 @@ import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>(); export const setupRouter = new Hono<AppEnv>();
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
const SETUP_RATE_LIMIT = 10;
const SETUP_RATE_WINDOW_MS = 60 * 1000;
function checkSetupRateLimit(ip: string): boolean {
const now = Date.now();
const entry = setupRateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
return true;
}
if (entry.count >= SETUP_RATE_LIMIT) {
return false;
}
entry.count++;
return true;
}
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { setupRouter.get("/status", async (c) => {
@@ -185,6 +204,11 @@ const authProviderTestSchema = z.object({
* After setup completes, this endpoint permanently returns 403. * After setup completes, this endpoint permanently returns 403.
*/ */
setupRouter.post("/auth-provider", async (c) => { setupRouter.post("/auth-provider", async (c) => {
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!checkSetupRateLimit(ip)) {
return c.json({ error: "Too many requests. Please try again later." }, 429);
}
const db = getDb(); const db = getDb();
// Guard: only allow during fresh install (no super user yet) // Guard: only allow during fresh install (no super user yet)
@@ -254,6 +278,11 @@ setupRouter.post("/auth-provider", async (c) => {
* Only available when needsSetup is true (no super user = fresh install). * Only available when needsSetup is true (no super user = fresh install).
*/ */
setupRouter.post("/auth-provider/test", async (c) => { setupRouter.post("/auth-provider/test", async (c) => {
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!checkSetupRateLimit(ip)) {
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
}
const db = getDb(); const db = getDb();
// Guard: only allow during fresh install (no super user yet) // Guard: only allow during fresh install (no super user yet)