Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/api/src/auth/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { randomBytes, timingSafeEqual } from "node:crypto";

export type SessionBoundaryFailure = {
status: 403;
body: { error: string };
};

export function billingMutationOrigin(req: any): boolean {
if (!req.userId || req.authKind !== "session") return false;
const originHeader =
typeof req.headers.origin === "string" ? req.headers.origin : null;
const refererHeader =
typeof req.headers.referer === "string" ? req.headers.referer : null;
let origin = originHeader;
if (!origin && refererHeader) {
try {
origin = new URL(refererHeader).origin;
} catch {
origin = null;
}
}
if (!origin) return false;
const allowed = new Set<string>();
for (const value of [process.env.API_PUBLIC_URL, process.env.WEB_CLIENT]) {
if (!value) continue;
try {
allowed.add(new URL(value).origin);
} catch {
// Startup configuration validation reports malformed URLs.
}
}
return allowed.has(origin);
}

function cookieValue(req: any, name: string): string | null {
const header = typeof req.headers?.cookie === "string" ? req.headers.cookie : "";
for (const part of header.split(";")) {
const [key, ...value] = part.trim().split("=");
if (key === name) return value.join("=") || null;
}
return null;
}

export function ensureCsrfCookie(req: any, res: any): string {
const existing = cookieValue(req, "sendlit_csrf");
if (existing) return existing;
const token = randomBytes(32).toString("base64url");
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `sendlit_csrf=${token}; Path=/; SameSite=Lax${secure}`);
return token;
}

function csrfTokenMatches(req: any, res: any): boolean {
const cookie = cookieValue(req, "sendlit_csrf");
const header =
typeof req.headers?.["x-sendlit-csrf"] === "string"
? req.headers["x-sendlit-csrf"]
: "";
if (!cookie || !header) {
ensureCsrfCookie(req, res);
return false;
}
const left = Buffer.from(cookie);
const right = Buffer.from(header);
return left.length === right.length && timingSafeEqual(left, right);
}

/** Session-only, same-origin and CSRF checks for sensitive dashboard writes
* (billing actions, account deletion). */
export function dashboardSessionBoundary(
req: any,
res: any,
): SessionBoundaryFailure | null {
if (!req.userId || req.authKind !== "session") {
return {
status: 403,
body: { error: "billing_human_session_required" },
};
}
if (!billingMutationOrigin(req)) {
return { status: 403, body: { error: "csrf_origin_invalid" } };
}
if (!csrfTokenMatches(req, res)) {
return { status: 403, body: { error: "csrf_token_invalid" } };
}
return null;
}
91 changes: 91 additions & 0 deletions apps/api/src/auth/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, mock, vi } from "bun:test";

mock.module("./better-auth", () => ({
SESSION_COOKIE_NAME: "sendlit.session_token",
mcpResourceUrl: "https://sendlit.test/mcp",
mcpProtectedResourceMetadataUrl:
"https://sendlit.test/.well-known/oauth-protected-resource/mcp",
}));
Expand Down Expand Up @@ -87,3 +88,93 @@ describe("MCP auth middleware", () => {
expect(JSON.stringify(authState)).not.toContain("sl_live_secret");
});
});

describe("REST auth middleware", () => {
const oauth = (audiences: string[]) => ({
status: "authenticated" as const,
kind: "oauth" as const,
identity: { method: "oauth" as const, audiences },
user: { id: "user-1" },
userId: "user-1",
token: "t",
clientId: "client",
scopes: ["contacts:read"],
});
const session = {
status: "authenticated" as const,
kind: "session" as const,
identity: { method: "session" as const },
user: { id: "user-1" },
userId: "user-1",
scopes: ["web"] as ["web"],
};

async function run(auth: unknown, req: Record<string, unknown>) {
const middleware = createAuthMiddleware("rest", vi.fn(async () => auth) as any);
const request: any = { headers: {}, body: undefined, method: "GET", ...req };
const res = responseDouble();
const next = vi.fn();
await middleware(request, res, next);
return { res, next, request };
}

it("refuses an OAuth token that is only valid for the MCP resource", async () => {
const { res, next } = await run(oauth(["https://sendlit.test/mcp"]), {});
expect(res.statusCode).toBe(401);
expect(res.body).toMatchObject({ error: "invalid_token" });
expect(next).not.toHaveBeenCalled();
});

it("still accepts an OAuth token issued for the API itself", async () => {
const { next } = await run(oauth(["https://sendlit.test"]), {});
expect(next).toHaveBeenCalledTimes(1);
});

it("refuses a cookie-session write that has no CSRF token", async () => {
// The attacker page is same-origin, so its Origin is allowed: only the
// token can stop it.
process.env.API_PUBLIC_URL = "https://sendlit.test";
const { res, next } = await run(session, {
method: "POST",
headers: { origin: "https://sendlit.test", cookie: "sendlit_csrf=abc" },
});
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "csrf_token_invalid" });
expect(next).not.toHaveBeenCalled();
});

it("refuses a cookie-session write from another origin", async () => {
process.env.API_PUBLIC_URL = "https://sendlit.test";
const { res } = await run(session, {
method: "DELETE",
headers: { origin: "https://evil.test" },
});
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "csrf_origin_invalid" });
});

it("allows a cookie-session write with a trusted origin and matching token", async () => {
process.env.API_PUBLIC_URL = "https://sendlit.test";
const { next } = await run(session, {
method: "POST",
headers: {
origin: "https://sendlit.test",
cookie: "sendlit_csrf=abc",
"x-sendlit-csrf": "abc",
},
});
expect(next).toHaveBeenCalledTimes(1);
});

it("does not require a token for reads or for API keys", async () => {
expect((await run(session, { method: "GET" })).next).toHaveBeenCalledTimes(1);
const key = {
status: "authenticated",
kind: "team_key",
user: null,
apiKeyId: "k",
teamId: "t",
};
expect((await run(key, { method: "POST" })).next).toHaveBeenCalledTimes(1);
});
});
31 changes: 30 additions & 1 deletion apps/api/src/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextFunction, Request, Response } from "express";
import { mcpProtectedResourceMetadataUrl } from "./better-auth";
import { mcpProtectedResourceMetadataUrl, mcpResourceUrl } from "./better-auth";
import { dashboardSessionBoundary } from "./csrf";
import {
type AuthInput,
type AuthResult,
Expand All @@ -9,6 +10,8 @@ import {

type AuthResolver = (input: AuthInput) => Promise<AuthResult>;

const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

type AuthMiddlewareMode = "rest" | "mcp";

function applyAuthToRequest(req: any, auth: AuthResult, mode: AuthMiddlewareMode) {
Expand Down Expand Up @@ -84,7 +87,33 @@ export function createAuthMiddleware(
return;
}

// OAuth tokens are issued for the MCP resource only. REST has no scope
// map, so honouring one there would turn a narrowly scoped MCP grant
// into the user's full authority (including minting team API keys).
if (
mode === "rest" &&
auth.kind === "oauth" &&
!auth.identity.audiences?.some((audience) => audience !== mcpResourceUrl)
) {
res.status(401).json({
error: "invalid_token",
error_description: "This token is only valid for the MCP resource.",
});
return;
}

applyAuthToRequest(req, auth, mode);

// A session cookie is sent by the browser on any request to this origin,
// including a form on a tenant-published page. State changes therefore
// need the dashboard's Origin plus double-submit CSRF token.
if (mode === "rest" && auth.kind === "session" && !SAFE_METHODS.has(req.method)) {
const failure = dashboardSessionBoundary(req, res);
if (failure) {
res.status(failure.status).json(failure.body);
return;
}
}
if (auth.kind === "oauth" || auth.kind === "session") {
req.auth = auth.identity;
}
Expand Down
90 changes: 8 additions & 82 deletions apps/api/src/billing/security.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { BillingWorkflowError } from "@codelitdev/billing/core";
import type {
BillingActionGrant,
Expand All @@ -8,6 +8,7 @@ import type { BillingGrantTarget } from "@codelitdev/platform/billing";
import { fromNodeHeaders } from "better-auth/node";
import { and, eq, gt, like, lte } from "drizzle-orm";
import { auth } from "../auth/better-auth";
import { dashboardSessionBoundary } from "../auth/csrf";
import { db } from "../db/client";
import { organizationMembers, organizations, verification } from "../db/schema";
import {
Expand Down Expand Up @@ -54,66 +55,6 @@ export const billingActions: readonly BillingAction[] = [
"pending_hide",
] as const;

export function billingMutationOrigin(req: any): boolean {
if (!req.userId || req.authKind !== "session") return false;
const originHeader =
typeof req.headers.origin === "string" ? req.headers.origin : null;
const refererHeader =
typeof req.headers.referer === "string" ? req.headers.referer : null;
let origin = originHeader;
if (!origin && refererHeader) {
try {
origin = new URL(refererHeader).origin;
} catch {
origin = null;
}
}
if (!origin) return false;
const allowed = new Set<string>();
for (const value of [process.env.API_PUBLIC_URL, process.env.WEB_CLIENT]) {
if (!value) continue;
try {
allowed.add(new URL(value).origin);
} catch {
// Startup configuration validation reports malformed URLs.
}
}
return allowed.has(origin);
}

function cookieValue(req: any, name: string): string | null {
const header = typeof req.headers?.cookie === "string" ? req.headers.cookie : "";
for (const part of header.split(";")) {
const [key, ...value] = part.trim().split("=");
if (key === name) return value.join("=") || null;
}
return null;
}

export function ensureCsrfCookie(req: any, res: any): string {
const existing = cookieValue(req, "sendlit_csrf");
if (existing) return existing;
const token = randomBytes(32).toString("base64url");
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `sendlit_csrf=${token}; Path=/; SameSite=Lax${secure}`);
return token;
}

function csrfTokenMatches(req: any, res: any): boolean {
const cookie = cookieValue(req, "sendlit_csrf");
const header =
typeof req.headers?.["x-sendlit-csrf"] === "string"
? req.headers["x-sendlit-csrf"]
: "";
if (!cookie || !header) {
ensureCsrfCookie(req, res);
return false;
}
const left = Buffer.from(cookie);
const right = Buffer.from(header);
return left.length === right.length && timingSafeEqual(left, right);
}

function tokenHash(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
Expand All @@ -130,27 +71,6 @@ async function sessionContext(req: any) {
return current;
}

/** Session-only, same-origin and CSRF checks for sensitive dashboard writes
* (billing actions, account deletion). */
export function dashboardSessionBoundary(
req: any,
res: any,
): BillingSecurityFailure | null {
if (!req.userId || req.authKind !== "session") {
return {
status: 403,
body: { error: "billing_human_session_required" },
};
}
if (!billingMutationOrigin(req)) {
return { status: 403, body: { error: "csrf_origin_invalid" } };
}
if (!csrfTokenMatches(req, res)) {
return { status: 403, body: { error: "csrf_token_invalid" } };
}
return null;
}

function isLegacyAction(action: BillingAction): action is LegacyAction {
return action === "organization_close" || action === "pending_hide";
}
Expand Down Expand Up @@ -462,3 +382,9 @@ export async function requireBillingAction(
return { status: 503, body: { error: "billing_security_unavailable" } };
}
}

export {
billingMutationOrigin,
dashboardSessionBoundary,
ensureCsrfCookie,
} from "../auth/csrf";
Loading
Loading