fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default
- API writes pass token:<name> for Bearer requests and "user" for sessions. - X-Requested-With: sttracker on every non-Bearer write, login and register included. - JSON-body routes reject non-JSON content types with 415. - A malformed stt_session cookie counts as no cookie (401), not 500. - DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only. - Bun.serve development mode only with NODE_ENV=development or --hot. - OpenAPI documents multipart fields. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aa8e4db070
commit
5f7d54b805
+32
-12
@@ -1,5 +1,6 @@
|
||||
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
||||
import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
||||
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
|
||||
import type { Actor } from "@sttracker/core/history";
|
||||
import { isUniqueViolation } from "@sttracker/core/matching";
|
||||
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
@@ -33,6 +34,8 @@ export interface Ctx<B, Q> {
|
||||
query: Q;
|
||||
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
||||
user: User;
|
||||
/** Who writes: `token:<name>` for Bearer requests, `"user"` for sessions. Pass it to every core write. */
|
||||
actor: Actor;
|
||||
/** The session token, when the request was authenticated by cookie. */
|
||||
sessionToken: string | null;
|
||||
/** Parsed multipart body, for `multipart: true` routes. */
|
||||
@@ -51,6 +54,8 @@ export interface RouteDef<B = any, Q = any> {
|
||||
query?: z.ZodType<Q>;
|
||||
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
||||
multipart?: boolean;
|
||||
/** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */
|
||||
formFields?: z.ZodObject;
|
||||
/** Rate limit per client IP (login, register). */
|
||||
rateLimited?: boolean;
|
||||
/** Only admins may call it (403 otherwise). */
|
||||
@@ -132,7 +137,13 @@ function readCookie(req: Request, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||
if (i > 0 && part.slice(0, i).trim() === name) {
|
||||
try {
|
||||
return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||
} catch {
|
||||
return null; // malformed percent-encoding: treat as no cookie
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -185,16 +196,20 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b
|
||||
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
||||
|
||||
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
|
||||
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
|
||||
if (mode === "none") return { user: null, sessionToken: null };
|
||||
async function authenticate(
|
||||
req: Request,
|
||||
mode: AuthMode,
|
||||
db: Db,
|
||||
): Promise<{ user: User | null; sessionToken: string | null; actor: Actor }> {
|
||||
if (mode === "none") return { user: null, sessionToken: null, actor: "user" };
|
||||
const authz = req.headers.get("authorization");
|
||||
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
||||
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
||||
|
||||
if (kind !== "session" && bearer) {
|
||||
const scope = scopeName!;
|
||||
const user = await verifyAppToken(db, bearer, scope);
|
||||
if (user) return { user, sessionToken: null };
|
||||
const auth = await authenticateAppToken(db, bearer, scope);
|
||||
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
|
||||
for (const other of SCOPES) {
|
||||
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
||||
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
||||
@@ -207,11 +222,7 @@ async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ use
|
||||
const token = readCookie(req, SESSION_COOKIE);
|
||||
const user = token ? await getSessionUser(db, token) : null;
|
||||
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
|
||||
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
|
||||
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||
}
|
||||
return { user, sessionToken: token };
|
||||
return { user, sessionToken: token, actor: "user" };
|
||||
}
|
||||
|
||||
interface Compiled {
|
||||
@@ -261,7 +272,13 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
||||
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
||||
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
||||
}
|
||||
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
|
||||
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
|
||||
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
|
||||
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
|
||||
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||
}
|
||||
const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db);
|
||||
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
||||
|
||||
let body: unknown;
|
||||
@@ -279,6 +296,8 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
||||
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
||||
let raw: unknown;
|
||||
if (bytes.byteLength > 0) {
|
||||
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json.");
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
@@ -299,6 +318,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
||||
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
|
||||
return user;
|
||||
},
|
||||
actor,
|
||||
sessionToken,
|
||||
get form(): FormData {
|
||||
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
||||
|
||||
Reference in New Issue
Block a user