release: WRNexusJS 0.7.0

This commit is contained in:
2026-08-01 10:04:42 +05:30
parent c54144f2e4
commit 87507edf59
207 changed files with 12607 additions and 679 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+5
View File
@@ -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
View File
@@ -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 });
}
+25
View File
@@ -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
+24 -4
View File
@@ -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,
+30
View File
@@ -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,
+129 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
});
}
+43
View File
@@ -159,3 +159,46 @@ test("room.state and count() track the live room", async () => {
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
});
test("realtime security enforces authentication, message size, rate, and safe shapes", async () => {
const violations: string[] = [];
const def = defineRoom({
security: {
requireUser: true,
maxMessageBytes: 64,
maxMessagesPerSecond: 1,
onViolation(reason) {
violations.push(reason);
},
},
});
const now = { value: 0 };
const reg = createRealtimeRegistry({ now: () => now.value });
const anonymousClosures: Array<[number | undefined, string | undefined]> = [];
const anonymous: RawSocket = {
send() {},
close(code, reason) {
anonymousClosures.push([code, reason]);
},
};
await reg.open(anonymous, { room: "/secure", def });
expect(anonymousClosures[0]).toEqual([1008, "Authentication required"]);
const closures: Array<[number | undefined, string | undefined]> = [];
const socket: RawSocket = {
send() {},
close(code, reason) {
closures.push([code, reason]);
},
};
await reg.open(socket, { room: "/secure", def, user: "u1" });
await reg.message(socket, JSON.stringify({ ok: true }));
await reg.message(socket, JSON.stringify({ ok: true }));
expect(closures.at(-1)).toEqual([1008, "Message rate exceeded"]);
expect(violations).toContain("message-rate-limit");
now.value = 2_000;
const unsafe = '{"constructor":{"prototype":{"admin":true}}}';
await reg.message(socket, unsafe);
expect(violations).toContain("invalid-message-shape");
});