release: WRNexusJS 0.7.0
This commit is contained in:
@@ -89,6 +89,8 @@ export interface CacheControlOptions {
|
||||
noCache?: boolean;
|
||||
/** stale-while-revalidate window in seconds. */
|
||||
staleWhileRevalidate?: number;
|
||||
/** stale-if-error window in seconds. */
|
||||
staleIfError?: number;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
@@ -104,6 +106,9 @@ export function cacheControl(options: CacheControlOptions): string {
|
||||
if (options.staleWhileRevalidate !== undefined) {
|
||||
parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
|
||||
}
|
||||
if (options.staleIfError !== undefined) {
|
||||
parts.push(`stale-if-error=${Math.max(0, Math.floor(options.staleIfError))}`);
|
||||
}
|
||||
if (options.immutable) parts.push("immutable");
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
+32
-24
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* CSRF protection via the double-submit cookie pattern.
|
||||
*
|
||||
* The framework sets a readable `wire-csrf` cookie on page loads; the client
|
||||
* echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form
|
||||
* runtime does this automatically). The server checks header === cookie. A
|
||||
* cross-site attacker can't read the cookie to forge the header, so the request
|
||||
* is rejected — while same-origin requests pass.
|
||||
* CSRF protection via the double-submit cookie pattern plus origin/fetch
|
||||
* metadata validation for unsafe requests.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
@@ -15,12 +10,20 @@ export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export interface CsrfProtectionOptions {
|
||||
/** Validate Origin when present. Defaults to true. */
|
||||
verifyOrigin?: boolean;
|
||||
/** Additional exact origins permitted for trusted cross-origin clients. */
|
||||
trustedOrigins?: string[];
|
||||
/** Reject Sec-Fetch-Site: cross-site on unsafe requests. Defaults to true. */
|
||||
verifyFetchMetadata?: boolean;
|
||||
}
|
||||
|
||||
/** Ensure the CSRF cookie exists (readable by JS) and return its token. */
|
||||
export function csrfToken(ctx: Context): string {
|
||||
let token = ctx.cookies.get(CSRF_COOKIE);
|
||||
if (!token) {
|
||||
token = crypto.randomUUID().replace(/-/g, "");
|
||||
// Readable by JS (double-submit needs it) but Secure on HTTPS.
|
||||
ctx.cookies.set(CSRF_COOKIE, token, {
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
@@ -30,33 +33,38 @@ export function csrfToken(ctx: Context): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an unsafe request's CSRF token against the cookie. Safe methods
|
||||
* (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token`
|
||||
* header or a `_csrf` field already parsed onto `ctx.locals`.
|
||||
*/
|
||||
export function verifyCsrf(ctx: Context): boolean {
|
||||
/** Verify an unsafe request's token, origin, and browser fetch metadata. */
|
||||
export function verifyCsrf(ctx: Context, options: CsrfProtectionOptions = {}): boolean {
|
||||
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
|
||||
|
||||
if (options.verifyFetchMetadata !== false) {
|
||||
const site = ctx.req.headers.get("sec-fetch-site");
|
||||
if (site === "cross-site") return false;
|
||||
}
|
||||
|
||||
if (options.verifyOrigin !== false) {
|
||||
const origin = ctx.req.headers.get("origin");
|
||||
if (origin) {
|
||||
const trusted = new Set([ctx.url.origin, ...(options.trustedOrigins ?? [])]);
|
||||
if (!trusted.has(origin)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = ctx.cookies.get(CSRF_COOKIE);
|
||||
const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined);
|
||||
return !!cookie && !!sent && timingSafeEqual(cookie, sent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison — the running time does not depend on where
|
||||
* the first differing byte is, so an attacker can't time-probe the token.
|
||||
*/
|
||||
/** Constant-time string comparison. */
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
let diff = a.length ^ b.length;
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
for (let i = 0; i < max; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Middleware that 403s unsafe requests with a missing/mismatched CSRF token. */
|
||||
export function csrfProtection(): Middleware {
|
||||
/** Middleware that 403s unsafe requests with a missing/mismatched token. */
|
||||
export function csrfProtection(options: CsrfProtectionOptions = {}): Middleware {
|
||||
return (ctx, next) =>
|
||||
verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
verifyCsrf(ctx, options) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
}
|
||||
|
||||
@@ -60,9 +60,23 @@ export interface TrustedTypesConfig {
|
||||
|
||||
export type PermissionsPolicyConfig = Record<string, string | string[] | false | null | undefined>;
|
||||
|
||||
export interface RequestLimitsConfig {
|
||||
maxUrlLength?: number;
|
||||
maxHeaderCount?: number;
|
||||
maxHeaderBytes?: number;
|
||||
maxQueryParameters?: number;
|
||||
maxBodyBytes?: number;
|
||||
timeoutMs?: number;
|
||||
maxConcurrent?: number;
|
||||
trustedHosts?: string[];
|
||||
fetchMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
/** Set false to skip all framework security headers except explicitly enabled CORS. */
|
||||
headers?: boolean;
|
||||
/** Built-in request size, timeout, concurrency, host, and Fetch Metadata limits. */
|
||||
requestLimits?: RequestLimitsConfig;
|
||||
/**
|
||||
* Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set
|
||||
* this when the app runs behind a TLS-terminating reverse proxy (nginx, the
|
||||
@@ -81,6 +95,12 @@ export interface SecurityConfig {
|
||||
frameOptions?: false | "DENY" | "SAMEORIGIN";
|
||||
/** Defaults to "strict-origin-when-cross-origin". */
|
||||
referrerPolicy?: false | string;
|
||||
/** Defaults to "same-origin". */
|
||||
crossOriginResourcePolicy?: false | "same-origin" | "same-site" | "cross-origin";
|
||||
/** Isolate the origin in its own agent cluster. Defaults to true. */
|
||||
originAgentCluster?: boolean;
|
||||
/** Disable speculative DNS prefetching. Defaults to true. */
|
||||
disableDnsPrefetch?: boolean;
|
||||
/** Defaults to a restrictive browser capability policy. */
|
||||
permissionsPolicy?: false | PermissionsPolicyConfig;
|
||||
/** Extra static headers applied last. */
|
||||
@@ -238,6 +258,11 @@ function applyBaseSecurityHeaders(
|
||||
const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin";
|
||||
if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy);
|
||||
|
||||
const corp = security?.crossOriginResourcePolicy ?? "same-origin";
|
||||
if (corp !== false) headers.set("Cross-Origin-Resource-Policy", corp);
|
||||
if (security?.originAgentCluster !== false) headers.set("Origin-Agent-Cluster", "?1");
|
||||
if (security?.disableDnsPrefetch !== false) headers.set("X-DNS-Prefetch-Control", "off");
|
||||
|
||||
const configuredPermissions = security?.permissionsPolicy;
|
||||
const permissionsPolicy =
|
||||
configuredPermissions === false
|
||||
|
||||
@@ -14,6 +14,7 @@ export { createContext, withContextHeaders } from "./context.ts";
|
||||
|
||||
export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts";
|
||||
export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts";
|
||||
export type { CsrfProtectionOptions } from "./csrf.ts";
|
||||
|
||||
export {
|
||||
hashPassword,
|
||||
@@ -36,8 +37,23 @@ export type { RequestLoggerOptions, RequestRecord } from "./logging.ts";
|
||||
export { TTLCache, cacheControl, withCacheControl, etag, notModified } from "./cache.ts";
|
||||
export type { CacheControlOptions } from "./cache.ts";
|
||||
|
||||
export { saveUpload, collectUploads, sanitizeFilename, UploadError } from "./uploads.ts";
|
||||
export type { SaveUploadOptions, SavedUpload } from "./uploads.ts";
|
||||
export {
|
||||
saveUpload,
|
||||
saveUploadSecure,
|
||||
collectUploads,
|
||||
sanitizeFilename,
|
||||
randomUploadFilename,
|
||||
secureDownloadHeaders,
|
||||
UploadError,
|
||||
} from "./uploads.ts";
|
||||
export type {
|
||||
SaveUploadOptions,
|
||||
SecureUploadOptions,
|
||||
SavedUpload,
|
||||
UploadInspectionResult,
|
||||
UploadInspector,
|
||||
UploadScanner,
|
||||
} from "./uploads.ts";
|
||||
|
||||
export { streamResponse, sse } from "./stream.ts";
|
||||
export type { StreamResponseInit, ServerSentEvent } from "./stream.ts";
|
||||
@@ -63,6 +79,8 @@ export type {
|
||||
RealtimeConnectMeta,
|
||||
RealtimeBridge,
|
||||
RealtimeEnvelope,
|
||||
RealtimeSecurityOptions,
|
||||
RealtimeRegistryOptions,
|
||||
} from "./realtime.ts";
|
||||
|
||||
export type { Mode } from "./errors.ts";
|
||||
@@ -81,6 +99,7 @@ export type {
|
||||
CspDirectiveValue,
|
||||
HstsConfig,
|
||||
PermissionsPolicyConfig,
|
||||
RequestLimitsConfig,
|
||||
SecurityConfig,
|
||||
TrustedTypesConfig,
|
||||
} from "./headers.ts";
|
||||
@@ -99,8 +118,9 @@ export type {
|
||||
SessionBackend,
|
||||
SessionEntry,
|
||||
AsyncSessionBackend,
|
||||
SessionPolicy,
|
||||
} from "./storage.ts";
|
||||
export { setSessionBackend, loadSession } from "./storage.ts";
|
||||
export { setSessionBackend, setSessionPolicy, loadSession } from "./storage.ts";
|
||||
|
||||
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
|
||||
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
|
||||
@@ -132,7 +152,7 @@ export type { Span, SpanRecord, Tracer } from "./observability.ts";
|
||||
export { defineFeatureFlags } from "./features.ts";
|
||||
export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
|
||||
|
||||
export { checkPerformanceBudgets } from "./performance.ts";
|
||||
export { checkPerformanceBudgets, recommendedWebBudgets } from "./performance.ts";
|
||||
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
|
||||
export {
|
||||
problem,
|
||||
|
||||
@@ -5,6 +5,20 @@ export interface PerformanceBudgets {
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
/** Largest Contentful Paint in milliseconds. Recommended <= 2500. */
|
||||
lcpMs?: number;
|
||||
/** Interaction to Next Paint in milliseconds. Recommended <= 200. */
|
||||
inpMs?: number;
|
||||
/** Cumulative Layout Shift score. Recommended <= 0.1. */
|
||||
cls?: number;
|
||||
/** Time to First Byte in milliseconds. */
|
||||
ttfbMs?: number;
|
||||
/** Longest main-thread task in milliseconds. Recommended <= 50. */
|
||||
longTaskMs?: number;
|
||||
/** Number of client hydration boundaries on the route. */
|
||||
hydratedComponents?: number;
|
||||
/** Total request count for the initial navigation. */
|
||||
requests?: number;
|
||||
}
|
||||
|
||||
export interface PerformanceMeasurement {
|
||||
@@ -14,6 +28,13 @@ export interface PerformanceMeasurement {
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
lcpMs?: number;
|
||||
inpMs?: number;
|
||||
cls?: number;
|
||||
ttfbMs?: number;
|
||||
longTaskMs?: number;
|
||||
hydratedComponents?: number;
|
||||
requests?: number;
|
||||
}
|
||||
|
||||
export interface BudgetViolation {
|
||||
@@ -23,6 +44,15 @@ export interface BudgetViolation {
|
||||
overBy: number;
|
||||
}
|
||||
|
||||
export const recommendedWebBudgets: Readonly<PerformanceBudgets> = Object.freeze({
|
||||
lcpMs: 2500,
|
||||
inpMs: 200,
|
||||
cls: 0.1,
|
||||
longTaskMs: 50,
|
||||
routeJsBytes: 50 * 1024,
|
||||
routeCssBytes: 25 * 1024,
|
||||
});
|
||||
|
||||
export function checkPerformanceBudgets(
|
||||
budgets: PerformanceBudgets,
|
||||
measurement: PerformanceMeasurement,
|
||||
|
||||
@@ -100,7 +100,28 @@ export interface RoomAuthInfo {
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface RealtimeSecurityOptions {
|
||||
/** Maximum inbound or outbound serialized message size. Defaults to 64 KiB. */
|
||||
maxMessageBytes?: number;
|
||||
/** Maximum messages accepted per connection per rolling second. Defaults to 30. */
|
||||
maxMessagesPerSecond?: number;
|
||||
/** Maximum live connections in one room. Defaults to 1,000. */
|
||||
maxConnectionsPerRoom?: number;
|
||||
/** Maximum connections for one authenticated user in a room. Defaults to 10. */
|
||||
maxConnectionsPerUser?: number;
|
||||
/** Reject anonymous connections before onConnect. */
|
||||
requireUser?: boolean;
|
||||
/** Maximum nested JSON depth. Defaults to 32. */
|
||||
maxJsonDepth?: number;
|
||||
/** Optional message schema/authorization predicate. */
|
||||
validateMessage?(message: unknown, client: RoomClient): boolean | Promise<boolean>;
|
||||
/** Called when a connection is rejected or closed for a policy violation. */
|
||||
onViolation?(reason: string, client?: RoomClient): void;
|
||||
}
|
||||
|
||||
export interface RoomHandlers<TData = Record<string, unknown>> {
|
||||
/** Per-room abuse and payload controls. */
|
||||
security?: RealtimeSecurityOptions;
|
||||
/**
|
||||
* Gate the connection BEFORE it is accepted. Return false to reject the
|
||||
* upgrade with 403 (e.g. `authorize: (info) => !!info.user` to require auth).
|
||||
@@ -138,6 +159,8 @@ export function isRoomDefinition(value: unknown): value is RoomDefinition {
|
||||
|
||||
interface Conn {
|
||||
id: string;
|
||||
messageWindowStartedAt: number;
|
||||
messageCount: number;
|
||||
user?: string;
|
||||
data: Record<string, unknown>;
|
||||
query: Record<string, string>;
|
||||
@@ -179,6 +202,10 @@ export interface RealtimeBridge {
|
||||
publish(envelope: RealtimeEnvelope): void;
|
||||
}
|
||||
|
||||
export interface RealtimeRegistryOptions extends RealtimeSecurityOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface RealtimeRegistry {
|
||||
open(socket: RawSocket, meta: RealtimeConnectMeta): void | Promise<void>;
|
||||
message(socket: RawSocket, raw: string | Uint8Array): void | Promise<void>;
|
||||
@@ -191,16 +218,59 @@ export interface RealtimeRegistry {
|
||||
size(): number;
|
||||
}
|
||||
|
||||
const DANGEROUS_REALTIME_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function assertRealtimePayload(value: unknown, maxDepth: number, depth = 0): void {
|
||||
if (depth > maxDepth) throw new Error("Realtime payload nesting limit exceeded.");
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (DANGEROUS_REALTIME_KEYS.has(key)) throw new Error(`Dangerous realtime key '${key}'.`);
|
||||
assertRealtimePayload(child, maxDepth, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function byteLength(value: string | Uint8Array): number {
|
||||
return typeof value === "string" ? new TextEncoder().encode(value).byteLength : value.byteLength;
|
||||
}
|
||||
|
||||
function serialize(message: unknown): string {
|
||||
return typeof message === "string" ? message : JSON.stringify(message);
|
||||
}
|
||||
|
||||
function mergedRealtimeSecurity(
|
||||
globalOptions: RealtimeRegistryOptions,
|
||||
room: RoomDefinition,
|
||||
): Required<
|
||||
Pick<
|
||||
RealtimeSecurityOptions,
|
||||
| "maxMessageBytes"
|
||||
| "maxMessagesPerSecond"
|
||||
| "maxConnectionsPerRoom"
|
||||
| "maxConnectionsPerUser"
|
||||
| "requireUser"
|
||||
| "maxJsonDepth"
|
||||
>
|
||||
> &
|
||||
RealtimeSecurityOptions {
|
||||
return {
|
||||
maxMessageBytes: 64 * 1024,
|
||||
maxMessagesPerSecond: 30,
|
||||
maxConnectionsPerRoom: 1_000,
|
||||
maxConnectionsPerUser: 10,
|
||||
requireUser: false,
|
||||
maxJsonDepth: 32,
|
||||
...globalOptions,
|
||||
...room.handlers.security,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the registry that maps sockets ↔ rooms and drives room handlers. */
|
||||
export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
export function createRealtimeRegistry(options: RealtimeRegistryOptions = {}): RealtimeRegistry {
|
||||
const rooms = new Map<string, RoomImpl>();
|
||||
const bySocket = new Map<RawSocket, Conn>();
|
||||
let bridge: RealtimeBridge | null = null;
|
||||
let applyingRemote = false; // true while delivering a peer envelope (no re-publish)
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const publish = (envelope: RealtimeEnvelope): void => {
|
||||
if (bridge && !applyingRemote) bridge.publish(envelope);
|
||||
@@ -208,6 +278,17 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
|
||||
const send = (conn: Conn | undefined, payload: string): void => {
|
||||
if (!conn) return;
|
||||
const room = rooms.get(conn.roomName);
|
||||
const policy = room
|
||||
? mergedRealtimeSecurity(options, room.def)
|
||||
: ({ maxMessageBytes: options.maxMessageBytes ?? 64 * 1024 } as ReturnType<
|
||||
typeof mergedRealtimeSecurity
|
||||
>);
|
||||
if (byteLength(payload) > policy.maxMessageBytes) {
|
||||
policy.onViolation?.("outbound-message-too-large", conn.client);
|
||||
conn.socket.close(1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
conn.socket.send(payload);
|
||||
} catch {
|
||||
@@ -295,13 +376,31 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
|
||||
return {
|
||||
async open(socket, meta) {
|
||||
const policy = mergedRealtimeSecurity(options, meta.def);
|
||||
if (policy.requireUser && !meta.user) {
|
||||
policy.onViolation?.("authentication-required");
|
||||
socket.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
let room = rooms.get(meta.room);
|
||||
if (!room) {
|
||||
room = { name: meta.room, state: {}, def: meta.def, conns: new Map(), users: new Map() };
|
||||
rooms.set(meta.room, room);
|
||||
}
|
||||
if (room.conns.size >= policy.maxConnectionsPerRoom) {
|
||||
policy.onViolation?.("room-connection-limit");
|
||||
socket.close(1013, "Room is at capacity");
|
||||
return;
|
||||
}
|
||||
if (meta.user && (room.users.get(meta.user)?.size ?? 0) >= policy.maxConnectionsPerUser) {
|
||||
policy.onViolation?.("user-connection-limit");
|
||||
socket.close(1008, "Too many connections");
|
||||
return;
|
||||
}
|
||||
const conn: Conn = {
|
||||
id: randomId(),
|
||||
messageWindowStartedAt: now(),
|
||||
messageCount: 0,
|
||||
data: {},
|
||||
query: meta.query ?? {},
|
||||
socket,
|
||||
@@ -320,6 +419,23 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
if (!conn) return;
|
||||
const room = rooms.get(conn.roomName);
|
||||
if (!room) return;
|
||||
const policy = mergedRealtimeSecurity(options, room.def);
|
||||
if (byteLength(raw) > policy.maxMessageBytes) {
|
||||
policy.onViolation?.("inbound-message-too-large", conn.client);
|
||||
socket.close(1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
const timestamp = now();
|
||||
if (timestamp - conn.messageWindowStartedAt >= 1_000) {
|
||||
conn.messageWindowStartedAt = timestamp;
|
||||
conn.messageCount = 0;
|
||||
}
|
||||
conn.messageCount += 1;
|
||||
if (conn.messageCount > policy.maxMessagesPerSecond) {
|
||||
policy.onViolation?.("message-rate-limit", conn.client);
|
||||
socket.close(1008, "Message rate exceeded");
|
||||
return;
|
||||
}
|
||||
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||
let message: unknown;
|
||||
try {
|
||||
@@ -327,6 +443,18 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
} catch {
|
||||
message = text;
|
||||
}
|
||||
try {
|
||||
assertRealtimePayload(message, policy.maxJsonDepth);
|
||||
} catch {
|
||||
policy.onViolation?.("invalid-message-shape", conn.client);
|
||||
socket.close(1008, "Invalid message");
|
||||
return;
|
||||
}
|
||||
if (policy.validateMessage && !(await policy.validateMessage(message, conn.client))) {
|
||||
policy.onViolation?.("message-validation-failed", conn.client);
|
||||
socket.close(1008, "Message rejected");
|
||||
return;
|
||||
}
|
||||
await room.def.handlers.onMessage?.(conn.client, message);
|
||||
},
|
||||
|
||||
|
||||
+107
-21
@@ -37,15 +37,47 @@ export interface LocalStorageSnapshot {
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = "wrnexus.sid";
|
||||
/** Idle timeout: a session expires this long after its last access. */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
/** Run a background sweep after this many new sessions (bounds memory). */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
const SESSION_ABSOLUTE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const SESSION_GC_EVERY = 500;
|
||||
|
||||
export interface SessionPolicy {
|
||||
cookieName?: string;
|
||||
idleTimeoutMs?: number;
|
||||
absoluteTimeoutMs?: number;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
let sessionPolicy: Required<
|
||||
Pick<SessionPolicy, "cookieName" | "idleTimeoutMs" | "absoluteTimeoutMs" | "sameSite">
|
||||
> &
|
||||
Pick<SessionPolicy, "secure"> = {
|
||||
cookieName: SESSION_COOKIE,
|
||||
idleTimeoutMs: SESSION_TTL_MS,
|
||||
absoluteTimeoutMs: SESSION_ABSOLUTE_TTL_MS,
|
||||
sameSite: "Lax",
|
||||
};
|
||||
|
||||
export function setSessionPolicy(policy: SessionPolicy): void {
|
||||
sessionPolicy = {
|
||||
...sessionPolicy,
|
||||
...policy,
|
||||
idleTimeoutMs: Math.max(60_000, policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs),
|
||||
absoluteTimeoutMs: Math.max(
|
||||
policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs,
|
||||
policy.absoluteTimeoutMs ?? sessionPolicy.absoluteTimeoutMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored session: its data plus an absolute expiry timestamp (ms). */
|
||||
export interface SessionEntry {
|
||||
data: Record<string, unknown>;
|
||||
expiresAt: number;
|
||||
/** Creation time used for the absolute session lifetime. Optional for old backends. */
|
||||
createdAt?: number;
|
||||
lastAccessAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +102,13 @@ function createMemorySessionBackend(): SessionBackend {
|
||||
set: (id, entry) => void map.set(id, entry),
|
||||
delete: (id) => void map.delete(id),
|
||||
gc: (now) => {
|
||||
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
|
||||
for (const [key, entry] of map) {
|
||||
if (
|
||||
entry.expiresAt <= now ||
|
||||
(entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now
|
||||
)
|
||||
map.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -102,13 +140,26 @@ export interface AsyncSessionBackend {
|
||||
*/
|
||||
export function loadSession(
|
||||
backend: AsyncSessionBackend,
|
||||
options: { ttlMs?: number } = {},
|
||||
options: {
|
||||
ttlMs?: number;
|
||||
absoluteTtlMs?: number;
|
||||
cookieName?: string;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
} = {},
|
||||
): Middleware {
|
||||
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
|
||||
const ttlMs = options.ttlMs ?? sessionPolicy.idleTimeoutMs;
|
||||
const absoluteTtlMs = options.absoluteTtlMs ?? sessionPolicy.absoluteTimeoutMs;
|
||||
const cookieName = options.cookieName ?? sessionPolicy.cookieName;
|
||||
return async (ctx: Context, next) => {
|
||||
let id = ctx.cookies.get(SESSION_COOKIE);
|
||||
let id = ctx.cookies.get(cookieName);
|
||||
let entry = id ? await backend.load(id) : undefined;
|
||||
if (id && entry && entry.expiresAt <= Date.now()) {
|
||||
if (
|
||||
id &&
|
||||
entry &&
|
||||
(entry.expiresAt <= Date.now() ||
|
||||
(entry.createdAt ?? Date.now()) + absoluteTtlMs <= Date.now())
|
||||
) {
|
||||
await backend.destroy(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
@@ -120,9 +171,16 @@ export function loadSession(
|
||||
const ensure = (): Record<string, unknown> => {
|
||||
if (!id) {
|
||||
id = randomId();
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
}
|
||||
if (!entry) {
|
||||
const now = Date.now();
|
||||
entry = { data: {}, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
}
|
||||
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
@@ -147,14 +205,22 @@ export function loadSession(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) destroys.add(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + ttlMs };
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
const now = Date.now();
|
||||
entry = { data, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
clear() {
|
||||
if (id) destroys.add(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.delete(
|
||||
cookieName,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -164,6 +230,7 @@ export function loadSession(
|
||||
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
|
||||
if (id && entry) {
|
||||
entry.expiresAt = Date.now() + ttlMs;
|
||||
entry.lastAccessAt = Date.now();
|
||||
await backend.save(id, entry);
|
||||
}
|
||||
}
|
||||
@@ -176,11 +243,14 @@ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
function readSessionEntry(id: string): SessionEntry | undefined {
|
||||
const entry = sessionBackend.get(id);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now) {
|
||||
sessionBackend.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
|
||||
entry.lastAccessAt = now;
|
||||
entry.createdAt ??= now;
|
||||
entry.expiresAt = now + sessionPolicy.idleTimeoutMs; // sliding idle expiry
|
||||
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
|
||||
return entry;
|
||||
}
|
||||
@@ -223,8 +293,8 @@ export function createCookieStore(req: Request): CookieStore {
|
||||
export function createSessionStore(
|
||||
cookies: CookieStore,
|
||||
req: Request,
|
||||
cookieName = SESSION_COOKIE,
|
||||
secure = new URL(req.url).protocol === "https:",
|
||||
cookieName = sessionPolicy.cookieName,
|
||||
secure = sessionPolicy.secure ?? new URL(req.url).protocol === "https:",
|
||||
): SessionStore {
|
||||
let id = cookies.get(cookieName);
|
||||
let entry = id ? readSessionEntry(id) : undefined;
|
||||
@@ -245,7 +315,13 @@ export function createSessionStore(
|
||||
sessionsSinceGc = 0;
|
||||
sessionBackend.gc?.(Date.now());
|
||||
}
|
||||
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data: {},
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
}
|
||||
return entry.data;
|
||||
@@ -278,7 +354,13 @@ export function createSessionStore(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) sessionBackend.delete(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data,
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
cookies.set(cookieName, id, sessionCookieOptions(secure));
|
||||
},
|
||||
@@ -341,11 +423,14 @@ function serializeCookie(name: string, value: string, options: CookieOptions): s
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function sessionCookieOptions(secure: boolean): CookieOptions {
|
||||
function sessionCookieOptions(
|
||||
secure: boolean,
|
||||
sameSite: NonNullable<CookieOptions["sameSite"]> = sessionPolicy.sameSite,
|
||||
): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
sameSite,
|
||||
secure,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +443,7 @@ function parseLocalStorageHeader(header: string | null): Record<string, string>
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") continue;
|
||||
if (typeof value === "string") out[key] = value;
|
||||
}
|
||||
return out;
|
||||
|
||||
+160
-22
@@ -1,19 +1,41 @@
|
||||
/**
|
||||
* File upload helpers. Bun parses `multipart/form-data` natively via
|
||||
* `Request.formData()`, yielding web `File` objects; these helpers validate and
|
||||
* persist them safely (size/type limits, filename sanitisation to prevent path
|
||||
* traversal).
|
||||
* File upload helpers. The legacy `saveUpload` keeps the original sanitized
|
||||
* filename for compatibility. New applications should use `saveUploadSecure`,
|
||||
* which stores a random name and supports content inspection/scanning hooks.
|
||||
*/
|
||||
|
||||
export class UploadError extends Error {
|
||||
constructor(message: string) {
|
||||
readonly code: string;
|
||||
constructor(message: string, code = "WRN-UPLOAD-REJECTED") {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadInspectionResult {
|
||||
allowed: boolean;
|
||||
detectedType?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type UploadInspector = (input: {
|
||||
file: File;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}) => UploadInspectionResult | Promise<UploadInspectionResult>;
|
||||
|
||||
export type UploadScanner = (input: {
|
||||
file: File;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}) =>
|
||||
| boolean
|
||||
| { clean: boolean; reason?: string }
|
||||
| Promise<boolean | { clean: boolean; reason?: string }>;
|
||||
|
||||
export interface SaveUploadOptions {
|
||||
/** Destination directory. */
|
||||
/** Destination directory. Keep this outside the public web root. */
|
||||
dir: string;
|
||||
/** Reject files larger than this many bytes. */
|
||||
maxBytes?: number;
|
||||
@@ -21,6 +43,21 @@ export interface SaveUploadOptions {
|
||||
allowedTypes?: string[];
|
||||
/** Choose the stored filename. Default: the sanitised original name. */
|
||||
filename?: (file: File) => string;
|
||||
/** Content/magic-byte inspection hook. */
|
||||
inspect?: UploadInspector;
|
||||
/** Malware scanning hook. */
|
||||
scan?: UploadScanner;
|
||||
/** Called after validation but before persistence. */
|
||||
beforeSave?: (input: { file: File; bytes: Uint8Array; filename: string }) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SecureUploadOptions extends Omit<SaveUploadOptions, "filename"> {
|
||||
/** Preserve the original sanitized name instead of a random server name. */
|
||||
preserveOriginalName?: boolean;
|
||||
/** Optional custom secure filename generator. */
|
||||
filename?: (file: File) => string;
|
||||
/** Preserve a conservative extension on random filenames. Defaults to true. */
|
||||
preserveExtension?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedUpload {
|
||||
@@ -28,51 +65,152 @@ export interface SavedUpload {
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
detectedType?: string;
|
||||
}
|
||||
|
||||
/** All `File` values in a parsed form, with their field names. */
|
||||
export function collectUploads(form: FormData): { field: string; file: File }[] {
|
||||
export function collectUploads(
|
||||
form: FormData,
|
||||
options: { maxFiles?: number; maxTotalBytes?: number } = {},
|
||||
): { field: string; file: File }[] {
|
||||
const out: { field: string; file: File }[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const [field, value] of form) {
|
||||
if (value instanceof File && value.size > 0) out.push({ field, file: value });
|
||||
if (!(value instanceof File) || value.size <= 0) continue;
|
||||
out.push({ field, file: value });
|
||||
totalBytes += value.size;
|
||||
if (options.maxFiles !== undefined && out.length > options.maxFiles) {
|
||||
throw new UploadError(
|
||||
`Upload contains more than ${options.maxFiles} files.`,
|
||||
"WRN-UPLOAD-FILE-COUNT",
|
||||
);
|
||||
}
|
||||
if (options.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
|
||||
throw new UploadError(
|
||||
`Upload exceeds the ${options.maxTotalBytes}-byte aggregate limit.`,
|
||||
"WRN-UPLOAD-TOTAL-SIZE",
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate and write one uploaded file to disk. Throws `UploadError` on reject. */
|
||||
/** Validate and write one uploaded file using a compatibility filename policy. */
|
||||
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
|
||||
return persistUpload(
|
||||
file,
|
||||
options,
|
||||
sanitizeFilename(options.filename ? options.filename(file) : file.name || "upload"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Store an upload under a random server-generated name by default. */
|
||||
export async function saveUploadSecure(
|
||||
file: File,
|
||||
options: SecureUploadOptions,
|
||||
): Promise<SavedUpload> {
|
||||
const requested = options.filename?.(file);
|
||||
const filename = requested
|
||||
? sanitizeFilename(requested)
|
||||
: options.preserveOriginalName
|
||||
? sanitizeFilename(file.name || "upload")
|
||||
: randomUploadFilename(file.name, options.preserveExtension !== false);
|
||||
return persistUpload(file, options, filename);
|
||||
}
|
||||
|
||||
async function persistUpload(
|
||||
file: File,
|
||||
options: SaveUploadOptions,
|
||||
filename: string,
|
||||
): Promise<SavedUpload> {
|
||||
if (options.maxBytes !== undefined && file.size > options.maxBytes) {
|
||||
throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`);
|
||||
throw new UploadError(
|
||||
`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`,
|
||||
"WRN-UPLOAD-SIZE",
|
||||
);
|
||||
}
|
||||
if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
|
||||
throw new UploadError(`File type not allowed: ${file.type || file.name || "unknown"}`);
|
||||
throw new UploadError(
|
||||
`File type not allowed: ${file.type || file.name || "unknown"}`,
|
||||
"WRN-UPLOAD-TYPE",
|
||||
);
|
||||
}
|
||||
|
||||
const filename = sanitizeFilename(
|
||||
options.filename ? options.filename(file) : file.name || "upload",
|
||||
);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let detectedType: string | undefined;
|
||||
if (options.inspect) {
|
||||
const result = await options.inspect({ file, bytes, filename });
|
||||
if (!result.allowed) {
|
||||
throw new UploadError(result.reason ?? "File content is not allowed.", "WRN-UPLOAD-CONTENT");
|
||||
}
|
||||
detectedType = result.detectedType;
|
||||
}
|
||||
if (options.scan) {
|
||||
const result = await options.scan({ file, bytes, filename });
|
||||
const clean = typeof result === "boolean" ? result : result.clean;
|
||||
if (!clean) {
|
||||
throw new UploadError(
|
||||
typeof result === "boolean"
|
||||
? "File failed malware scanning."
|
||||
: (result.reason ?? "File failed malware scanning."),
|
||||
"WRN-UPLOAD-MALWARE",
|
||||
);
|
||||
}
|
||||
}
|
||||
await options.beforeSave?.({ file, bytes, filename });
|
||||
|
||||
const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`;
|
||||
await Bun.write(path, file);
|
||||
return { path, filename, size: file.size, type: file.type };
|
||||
await Bun.write(path, bytes);
|
||||
return {
|
||||
path,
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
...(detectedType ? { detectedType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowed(file: File, allowed: string[]): boolean {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
const name = (file.name || "").toLowerCase();
|
||||
return allowed.some((entry) => {
|
||||
const e = entry.toLowerCase();
|
||||
return e.startsWith(".") ? name.endsWith(e) : type === e;
|
||||
const candidate = entry.toLowerCase();
|
||||
if (candidate.startsWith(".")) return name.endsWith(candidate);
|
||||
if (candidate.endsWith("/*")) return type.startsWith(candidate.slice(0, -1));
|
||||
return type === candidate;
|
||||
});
|
||||
}
|
||||
|
||||
/** Strip directory separators, traversal, and control chars from a filename. */
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const base = name
|
||||
.replace(/[/\\]+/g, "_") // path separators
|
||||
.replace(/\.\.+/g, ".") // collapse traversal dots
|
||||
.replace(/[/\\]+/g, "_")
|
||||
.replace(/\.\.+/g, ".")
|
||||
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars
|
||||
.replace(/^\.+/, "") // no leading dots
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "")
|
||||
.replace(/^\.+/, "")
|
||||
.trim();
|
||||
return base.length > 0 ? base.slice(0, 255) : "upload";
|
||||
}
|
||||
|
||||
export function randomUploadFilename(originalName = "", preserveExtension = true): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const id = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
if (!preserveExtension) return id;
|
||||
const match = /(?:^|\.)([A-Za-z0-9]{1,10})$/.exec(originalName);
|
||||
return match ? `${id}.${match[1]!.toLowerCase()}` : id;
|
||||
}
|
||||
|
||||
export function secureDownloadHeaders(
|
||||
filename: string,
|
||||
type = "application/octet-stream",
|
||||
): Headers {
|
||||
const safe = sanitizeFilename(filename).replace(/["\\]/g, "_");
|
||||
return new Headers({
|
||||
"content-type": type,
|
||||
"content-disposition": `attachment; filename="${safe}"`,
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "private, no-store",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user