From 1a99c1223b02885d8b0b65880ad7692f20d272e7 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 2 Apr 2026 17:23:56 +0200 Subject: [PATCH] feat: add security middleware to server plugin (CSRF, CSP, CORS, error handler) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add secure-by-default security middleware to the AppKit server plugin, addressing gaps identified in the Web Security Guide for Lakehouse Internal Developers. The Databricks Apps proxy only provides authentication — apps must implement CSRF, CSP, CORS, and error handling themselves. New security features: - CSRF protection via Origin header validation (enabled by default) - Security headers via helmet (CSP, X-Content-Type-Options, X-Frame-Options, COOP) - CORS support via expressjs/cors (opt-in, disabled by default) - Global error handler preventing information disclosure - Configurable body size limit for JSON parsing All features are configurable via `security` option in ServerConfig and can be individually disabled. Dev mode auto-allows localhost for CSRF and relaxes CSP for Vite HMR. Signed-off-by: Pawel Kosiec --- packages/appkit/package.json | 3 + packages/appkit/src/plugins/server/index.ts | 8 + .../appkit/src/plugins/server/manifest.json | 69 ++++++ .../src/plugins/server/security/csrf.ts | 184 ++++++++++++++ .../plugins/server/security/error-handler.ts | 85 +++++++ .../src/plugins/server/security/index.ts | 140 +++++++++++ .../src/plugins/server/security/types.ts | 48 ++++ .../server/tests/security/csrf.test.ts | 228 ++++++++++++++++++ .../tests/security/error-handler.test.ts | 162 +++++++++++++ .../server/tests/security/security.test.ts | 169 +++++++++++++ packages/appkit/src/plugins/server/types.ts | 5 + pnpm-lock.yaml | 92 ++++--- 12 files changed, 1161 insertions(+), 32 deletions(-) create mode 100644 packages/appkit/src/plugins/server/security/csrf.ts create mode 100644 packages/appkit/src/plugins/server/security/error-handler.ts create mode 100644 packages/appkit/src/plugins/server/security/index.ts create mode 100644 packages/appkit/src/plugins/server/security/types.ts create mode 100644 packages/appkit/src/plugins/server/tests/security/csrf.test.ts create mode 100644 packages/appkit/src/plugins/server/tests/security/error-handler.test.ts create mode 100644 packages/appkit/src/plugins/server/tests/security/security.test.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 5d73da2fb..965558dc7 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -73,9 +73,11 @@ "@opentelemetry/sdk-trace-base": "2.6.0", "@opentelemetry/semantic-conventions": "1.38.0", "@types/semver": "7.7.1", + "cors": "^2.8.6", "dotenv": "16.6.1", "express": "4.22.0", "get-port": "7.2.0", + "helmet": "^8.1.0", "js-yaml": "4.1.1", "obug": "2.1.1", "pg": "8.18.0", @@ -88,6 +90,7 @@ }, "devDependencies": { "@opentelemetry/context-async-hooks": "2.6.1", + "@types/cors": "^2.8.19", "@types/express": "4.17.25", "@types/js-yaml": "4.0.9", "@types/json-schema": "7.0.15", diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index e66abf5ad..eebb1ee8e 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -14,6 +14,7 @@ import { instrumentations } from "../../telemetry"; import { sanitizeClientConfig } from "./client-config-sanitizer"; import manifest from "./manifest.json"; import { RemoteTunnelController } from "./remote-tunnel/remote-tunnel-controller"; +import { registerErrorHandler, registerSecurityMiddleware } from "./security"; import { StaticServer } from "./static-server"; import type { ServerConfig } from "./types"; import { getRoutes, type PluginEndpoints, printRoutes } from "./utils"; @@ -111,6 +112,10 @@ export class ServerPlugin extends Plugin { */ async start(): Promise { this.serverApplication.use(requestMetricsMiddleware); + + // Security middleware first — inspects headers only, no body needed + registerSecurityMiddleware(this.serverApplication, this.config.security); + this.serverApplication.use( express.json({ // Express's stock 100kb default is too tight for modern apps — @@ -147,6 +152,9 @@ export class ServerPlugin extends Plugin { await this.setupFrontend(endpoints, pluginConfigs); + // Error handler last — catches unhandled errors from API routes + registerErrorHandler(this.serverApplication, this.config.security); + const listenPort = await this.resolveListenPort(); const server = this.serverApplication.listen( diff --git a/packages/appkit/src/plugins/server/manifest.json b/packages/appkit/src/plugins/server/manifest.json index 1112fbf58..c7240b3c5 100644 --- a/packages/appkit/src/plugins/server/manifest.json +++ b/packages/appkit/src/plugins/server/manifest.json @@ -24,6 +24,75 @@ "staticPath": { "type": "string", "description": "Path to static files directory (auto-detected if not provided)" + }, + "bodyLimit": { + "type": "string", + "description": "JSON body size limit (e.g. '100kb', '1mb'). Default: '100kb'" + }, + "security": { + "type": "object", + "description": "Security configuration. Secure defaults applied when omitted.", + "properties": { + "csrf": { + "oneOf": [ + { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional trusted origins for CSRF validation" + } + } + }, + { "const": false } + ] + }, + "helmet": { + "oneOf": [ + { + "type": "object", + "description": "HelmetOptions — fully replaces defaults" + }, + { "const": false } + ] + }, + "cors": { + "oneOf": [ + { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { "type": "string" } + }, + "credentials": { "type": "boolean" }, + "maxAge": { "type": "number" }, + "allowedMethods": { + "type": "array", + "items": { "type": "string" } + }, + "allowedHeaders": { + "type": "array", + "items": { "type": "string" } + } + } + }, + { "const": false } + ] + }, + "errorHandler": { + "oneOf": [ + { + "type": "object", + "properties": { + "includeErrorCode": { "type": "boolean" } + } + }, + { "const": false } + ] + } + } } } } diff --git a/packages/appkit/src/plugins/server/security/csrf.ts b/packages/appkit/src/plugins/server/security/csrf.ts new file mode 100644 index 000000000..7b52d1fe5 --- /dev/null +++ b/packages/appkit/src/plugins/server/security/csrf.ts @@ -0,0 +1,184 @@ +import type { NextFunction, Request, Response } from "express"; +import { createLogger } from "../../../logging/logger"; +import type { CsrfConfig } from "./types"; + +const logger = createLogger("server"); + +const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]); + +/** + * Parse a comma-separated env var into trimmed, non-empty strings. + */ +function parseEnvOrigins(envVar: string | undefined): string[] { + if (!envVar) return []; + return envVar + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Build the set of trusted origins from all sources: + * 1. DATABRICKS_APP_URL env var + * 2. Config allowedOrigins + * 3. APPKIT_CSRF_ALLOWED_ORIGINS env var + */ +function buildTrustedOrigins(config?: CsrfConfig): Set { + const origins = new Set(); + + const appUrl = process.env.DATABRICKS_APP_URL; + if (appUrl) { + try { + origins.add(new URL(appUrl).origin.toLowerCase()); + } catch { + logger.warn( + "DATABRICKS_APP_URL is not a valid URL: %s — skipping for CSRF", + appUrl, + ); + } + } + + for (const o of config?.allowedOrigins ?? []) { + origins.add(o.toLowerCase().replace(/\/$/, "")); + } + + for (const o of parseEnvOrigins(process.env.APPKIT_CSRF_ALLOWED_ORIGINS)) { + origins.add(o.toLowerCase().replace(/\/$/, "")); + } + + return origins; +} + +/** + * Check if an origin matches localhost (any port). + */ +function isLocalhostOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return url.hostname === "localhost" || url.hostname === "127.0.0.1"; + } catch { + return false; + } +} + +/** + * Same-origin heuristic: compare Origin against Host header. + * Used as fallback when no trusted origins are configured. + */ +function isSameOrigin(origin: string, req: Request): boolean { + const host = req.headers.host; + if (!host) return false; + + try { + const originUrl = new URL(origin); + const originHost = originUrl.host.toLowerCase(); + return originHost === host.toLowerCase(); + } catch { + return false; + } +} + +/** + * Create CSRF protection middleware using Origin header validation. + * + * - Applies to state-changing methods (POST, PUT, DELETE, PATCH) only + * - Allows absent/empty Origin (same-origin browser or non-browser client) + * - Rejects `Origin: null` (sandboxed iframe attack vector) + * - In dev mode, auto-allows localhost origins + * - Falls back to Host header comparison when no trusted origins are configured + */ +export function createCsrfMiddleware( + config?: CsrfConfig | false, +): (req: Request, res: Response, next: NextFunction) => void { + if (config === false) { + return (_req, _res, next) => next(); + } + + const isDev = process.env.NODE_ENV === "development"; + const trustedOrigins = buildTrustedOrigins( + config === undefined ? undefined : config, + ); + + if (!isDev && trustedOrigins.size === 0) { + logger.warn( + "DATABRICKS_APP_URL not set and no CSRF origins configured — CSRF will use Host header fallback. Set DATABRICKS_APP_URL for full protection.", + ); + } + + return (req: Request, res: Response, next: NextFunction) => { + if (!STATE_CHANGING_METHODS.has(req.method)) { + return next(); + } + + const origin = req.headers.origin; + + // No Origin header — allow (same-origin or non-browser client) + if (!origin || origin === "") { + return next(); + } + + // Reject Origin: null (sandboxed iframe, data: URI) + if (origin === "null") { + logger.debug("CSRF rejected: null Origin on %s %s", req.method, req.path); + return res.status(403).json( + isDev + ? { + error: "CSRF validation failed", + detail: + "Origin: null rejected — possible sandboxed iframe or data: URI", + } + : { error: "CSRF validation failed" }, + ); + } + + const normalizedOrigin = origin.toLowerCase().replace(/\/$/, ""); + + // In dev mode, allow localhost origins + if (isDev && isLocalhostOrigin(normalizedOrigin)) { + return next(); + } + + // In production, reject non-HTTPS origins + if (!isDev && !normalizedOrigin.startsWith("https://")) { + logger.debug( + "CSRF rejected: non-HTTPS Origin %s on %s %s", + origin, + req.method, + req.path, + ); + return res.status(403).json( + isDev + ? { + error: "CSRF validation failed", + detail: `Origin must use HTTPS in production: ${origin}`, + } + : { error: "CSRF validation failed" }, + ); + } + + // Check against trusted origins + if (trustedOrigins.has(normalizedOrigin)) { + return next(); + } + + // Fallback: same-origin heuristic (compare Origin vs Host) + if (trustedOrigins.size === 0 && isSameOrigin(origin, req)) { + return next(); + } + + logger.debug( + "CSRF rejected: Origin %s not trusted on %s %s", + origin, + req.method, + req.path, + ); + return res.status(403).json( + isDev + ? { + error: "CSRF validation failed", + detail: `Origin ${origin} not in trusted set`, + } + : { error: "CSRF validation failed" }, + ); + }; +} diff --git a/packages/appkit/src/plugins/server/security/error-handler.ts b/packages/appkit/src/plugins/server/security/error-handler.ts new file mode 100644 index 000000000..8361c723c --- /dev/null +++ b/packages/appkit/src/plugins/server/security/error-handler.ts @@ -0,0 +1,85 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppKitError } from "../../../errors/base"; +import { createLogger } from "../../../logging/logger"; +import type { ErrorHandlerConfig } from "./types"; + +const logger = createLogger("server"); + +/** + * Create a global error handler middleware that prevents information disclosure. + * + * - Logs full error details server-side (using AppKitError.toJSON() for safe sanitization) + * - Returns generic error messages in production + * - Includes message/stack in dev mode for debugging + * - Handles SyntaxError from JSON body parsing (returns 400) + * - Respects headersSent to avoid double-send + */ +export function createErrorHandler( + config?: ErrorHandlerConfig | false, +): (err: Error, req: Request, res: Response, next: NextFunction) => void { + if (config === false) { + return (_err, _req, _res, next) => next(_err); + } + + const isDev = process.env.NODE_ENV === "development"; + const includeErrorCode = config?.includeErrorCode ?? true; + + return (err: Error, _req: Request, res: Response, next: NextFunction) => { + // If headers already sent, delegate to Express default handler + if (res.headersSent) { + return next(err); + } + + // Log the error server-side + if (err instanceof AppKitError) { + logger.error("Unhandled error: %O", err.toJSON()); + } else { + logger.error("Unhandled error: %s", err.message); + if (err.stack) { + logger.debug("Stack trace: %s", err.stack); + } + } + + // Handle JSON parsing errors from express.json() + if ( + err instanceof SyntaxError && + "status" in err && + (err as { status?: number }).status === 400 + ) { + return res + .status(400) + .json( + isDev + ? { error: "Bad Request", message: err.message } + : { error: "Bad Request" }, + ); + } + + // Handle AppKitError with proper status code + if (err instanceof AppKitError) { + const body: Record = { + error: isDev ? err.message : "Internal Server Error", + }; + + if (includeErrorCode) { + body.code = err.code; + } + + if (isDev && err.stack) { + body.stack = err.stack; + } + + return res.status(err.statusCode).json(body); + } + + // Generic error + return res.status(500).json( + isDev + ? { + error: err.message || "Internal Server Error", + stack: err.stack, + } + : { error: "Internal Server Error" }, + ); + }; +} diff --git a/packages/appkit/src/plugins/server/security/index.ts b/packages/appkit/src/plugins/server/security/index.ts new file mode 100644 index 000000000..5595f43e0 --- /dev/null +++ b/packages/appkit/src/plugins/server/security/index.ts @@ -0,0 +1,140 @@ +import corsMiddleware from "cors"; +import type { Application } from "express"; +import helmet from "helmet"; +import { createLogger } from "../../../logging/logger"; +import { createCsrfMiddleware } from "./csrf"; +import { createErrorHandler } from "./error-handler"; +import type { CorsConfig, SecurityConfig } from "./types"; + +const logger = createLogger("server"); + +/** + * Build the default Helmet options based on the environment. + */ +function getDefaultHelmetOptions(isDev: boolean) { + if (isDev) { + return { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'", "http:", "https:", "ws:", "wss:"], + scriptSrc: ["'self'", "'unsafe-inline'", "http:", "https:"], + styleSrc: ["'self'", "'unsafe-inline'", "http:", "https:"], + imgSrc: ["'self'", "http:", "https:", "data:", "blob:"], + fontSrc: ["'self'", "http:", "https:", "data:"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + connectSrc: ["'self'", "http:", "https:", "ws:", "wss:"], + frameAncestors: ["'self'"], + }, + }, + crossOriginOpenerPolicy: { policy: "same-origin" as const }, + }; + } + + return { + contentSecurityPolicy: { + directives: { + defaultSrc: ["https:", "wss:"], + scriptSrc: ["https:"], + styleSrc: ["'self'", "https:", "'unsafe-inline'"], + imgSrc: ["https:", "data:"], + fontSrc: ["https:", "data:"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + connectSrc: ["https:", "wss:"], + frameAncestors: ["'none'"], + }, + }, + crossOriginOpenerPolicy: { policy: "same-origin" as const }, + }; +} + +/** + * Build CORS options from CorsConfig + env var. + */ +function buildCorsOptions(config: CorsConfig) { + const origins = [ + ...(config.allowedOrigins ?? []), + ...(process.env.APPKIT_CORS_ALLOWED_ORIGINS?.split(",") + .map((s) => s.trim()) + .filter(Boolean) ?? []), + ]; + + return { + origin: origins.length > 0 ? origins : (false as false), + credentials: config.credentials ?? false, + maxAge: config.maxAge ?? 86400, + methods: config.allowedMethods ?? ["GET", "POST", "PUT", "DELETE", "PATCH"], + allowedHeaders: config.allowedHeaders ?? ["Content-Type", "Authorization"], + optionsSuccessStatus: 204, + }; +} + +/** + * Register security middleware on the Express application. + * + * Applied in order: + * 1. Helmet (security headers + CSP) + * 2. CORS (if enabled) + * 3. CSRF (origin validation) + * + * All middleware only inspect headers — no body parsing required. + * Must be registered before route handlers. + */ +export function registerSecurityMiddleware( + app: Application, + config?: SecurityConfig, +): void { + const isDev = process.env.NODE_ENV === "development"; + const features: string[] = []; + + // 1. Helmet (security headers) + if (config?.helmet !== false) { + const helmetOptions = + config?.helmet && typeof config.helmet === "object" + ? config.helmet // User-provided options fully replace defaults + : getDefaultHelmetOptions(isDev); + + app.use(helmet(helmetOptions)); + features.push("Helmet (CSP + security headers)"); + } + + // 2. CORS (opt-in) + if (config?.cors) { + const corsOptions = buildCorsOptions(config.cors); + app.use(corsMiddleware(corsOptions)); + features.push("CORS"); + } + + // 3. CSRF (origin validation) + if (config?.csrf !== false) { + const csrfConfig = + config?.csrf && typeof config.csrf === "object" ? config.csrf : undefined; + app.use(createCsrfMiddleware(csrfConfig)); + features.push("CSRF (origin validation)"); + } + + if (features.length > 0) { + logger.info("Security middleware enabled: %s", features.join(", ")); + } +} + +/** + * Register the global error handler middleware. + * + * Must be registered after all route handlers (Express convention). + * Acts as a safety net for unhandled errors — plugins can handle + * their own errors in route handlers without being affected. + */ +export function registerErrorHandler( + app: Application, + config?: SecurityConfig, +): void { + if (config?.errorHandler !== false) { + const errorConfig = + config?.errorHandler && typeof config.errorHandler === "object" + ? config.errorHandler + : undefined; + app.use(createErrorHandler(errorConfig)); + } +} diff --git a/packages/appkit/src/plugins/server/security/types.ts b/packages/appkit/src/plugins/server/security/types.ts new file mode 100644 index 000000000..ef2188ffb --- /dev/null +++ b/packages/appkit/src/plugins/server/security/types.ts @@ -0,0 +1,48 @@ +import type { HelmetOptions } from "helmet"; + +/** Security configuration for the server plugin. Secure defaults applied when omitted. */ +export interface SecurityConfig { + /** CSRF protection via Origin header validation. Enabled by default in production. Set `false` to disable. */ + csrf?: CsrfConfig | false; + + /** Helmet security headers (CSP, X-Content-Type-Options, X-Frame-Options, COOP, etc.). + * Enabled by default with secure presets. Pass custom HelmetOptions to fully replace defaults, or `false` to disable. */ + helmet?: HelmetOptions | false; + + /** CORS configuration. Disabled by default — not registered unless explicitly configured. */ + cors?: CorsConfig | false; + + /** Global error handler preventing info disclosure. Enabled by default. Set `false` to disable (e.g. if you have your own). */ + errorHandler?: ErrorHandlerConfig | false; +} + +export interface CsrfConfig { + /** + * Additional trusted origins for CSRF validation (beyond DATABRICKS_APP_URL). + * Also merged with APPKIT_CSRF_ALLOWED_ORIGINS env var (comma-separated). + * All sources are unioned and deduplicated. + */ + allowedOrigins?: string[]; +} + +export interface CorsConfig { + /** + * Allowed origins for CORS. Also merged with APPKIT_CORS_ALLOWED_ORIGINS env var (comma-separated). + * All sources are unioned and deduplicated. + * If empty after merging, CORS rejects all cross-origin requests (safe default). + */ + allowedOrigins?: string[]; + /** Allow credentials (cookies). Default: false */ + credentials?: boolean; + /** Preflight cache duration in seconds. Default: 86400 (24h) */ + maxAge?: number; + /** Allowed HTTP methods. Default: ["GET","POST","PUT","DELETE","PATCH"] */ + allowedMethods?: string[]; + /** Allowed request headers. Default: ["Content-Type","Authorization"] */ + allowedHeaders?: string[]; +} + +export interface ErrorHandlerConfig { + /** Include AppKitError.code in error responses. Default: true (codes are safe to expose and help clients handle errors). */ + includeErrorCode?: boolean; +} diff --git a/packages/appkit/src/plugins/server/tests/security/csrf.test.ts b/packages/appkit/src/plugins/server/tests/security/csrf.test.ts new file mode 100644 index 000000000..a5a7e3ce8 --- /dev/null +++ b/packages/appkit/src/plugins/server/tests/security/csrf.test.ts @@ -0,0 +1,228 @@ +import express from "express"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createCsrfMiddleware } from "../../security/csrf"; + +function createTestApp(config?: Parameters[0]) { + const app = express(); + app.use(createCsrfMiddleware(config)); + app.post("/test", (_req, res) => res.json({ ok: true })); + app.get("/test", (_req, res) => res.json({ ok: true })); + app.put("/test", (_req, res) => res.json({ ok: true })); + app.delete("/test", (_req, res) => res.json({ ok: true })); + app.patch("/test", (_req, res) => res.json({ ok: true })); + return app; +} + +async function request( + app: express.Application, + method: string, + path: string, + headers?: Record, +) { + const server = app.listen(0); + const addr = server.address() as { port: number }; + try { + const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, { + method, + headers, + }); + const body = (await res.json()) as Record; + return { status: res.status, body }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("CSRF Middleware", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DATABRICKS_APP_URL", "https://my-app.databricksapps.com"); + delete process.env.APPKIT_CSRF_ALLOWED_ORIGINS; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("method filtering", () => { + test("GET requests bypass CSRF", async () => { + const app = createTestApp(); + const res = await request(app, "GET", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(200); + }); + + test("POST requests are checked", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + }); + + test("PUT requests are checked", async () => { + const app = createTestApp(); + const res = await request(app, "PUT", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + }); + + test("DELETE requests are checked", async () => { + const app = createTestApp(); + const res = await request(app, "DELETE", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + }); + + test("PATCH requests are checked", async () => { + const app = createTestApp(); + const res = await request(app, "PATCH", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + }); + }); + + describe("origin validation", () => { + test("allows POST without Origin header (same-origin)", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test"); + expect(res.status).toBe(200); + }); + + test("rejects POST with Origin: null", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { origin: "null" }); + expect(res.status).toBe(403); + expect(res.body.error).toBe("CSRF validation failed"); + }); + + test("allows POST with matching DATABRICKS_APP_URL origin", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://my-app.databricksapps.com", + }); + expect(res.status).toBe(200); + }); + + test("rejects POST with non-matching origin", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + }); + + test("case-insensitive origin comparison", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://MY-APP.DATABRICKSAPPS.COM", + }); + expect(res.status).toBe(200); + }); + + test("rejects non-HTTPS origins in production", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "http://my-app.databricksapps.com", + }); + expect(res.status).toBe(403); + }); + }); + + describe("config allowedOrigins", () => { + test("allows additional configured origins", async () => { + const app = createTestApp({ + allowedOrigins: ["https://partner.example.com"], + }); + const res = await request(app, "POST", "/test", { + origin: "https://partner.example.com", + }); + expect(res.status).toBe(200); + }); + }); + + describe("APPKIT_CSRF_ALLOWED_ORIGINS env var", () => { + test("merges origins from env var", async () => { + vi.stubEnv( + "APPKIT_CSRF_ALLOWED_ORIGINS", + "https://extra1.com, https://extra2.com", + ); + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://extra1.com", + }); + expect(res.status).toBe(200); + }); + }); + + describe("Host header fallback", () => { + test("falls back to Host header when no trusted origins configured", async () => { + delete process.env.DATABRICKS_APP_URL; + const app = createTestApp(); + + // fetch sets Host header automatically + const server = app.listen(0); + const addr = server.address() as { port: number }; + try { + const res = await fetch(`http://127.0.0.1:${addr.port}/test`, { + method: "POST", + headers: { origin: `http://127.0.0.1:${addr.port}` }, + }); + // In production, non-HTTPS is rejected before fallback + expect(res.status).toBe(403); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + }); + + describe("dev mode", () => { + test("allows localhost origins in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.DATABRICKS_APP_URL; + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "http://localhost:5173", + }); + expect(res.status).toBe(200); + }); + + test("allows 127.0.0.1 origins in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.DATABRICKS_APP_URL; + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "http://127.0.0.1:8000", + }); + expect(res.status).toBe(200); + }); + + test("still rejects non-localhost origins in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.DATABRICKS_APP_URL; + const app = createTestApp(); + const res = await request(app, "POST", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(403); + // Dev mode includes detail + expect(res.body.detail).toBeDefined(); + }); + }); + + describe("disabled", () => { + test("csrf: false disables all CSRF checks", async () => { + const app = createTestApp(false); + const res = await request(app, "POST", "/test", { + origin: "https://evil.com", + }); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/packages/appkit/src/plugins/server/tests/security/error-handler.test.ts b/packages/appkit/src/plugins/server/tests/security/error-handler.test.ts new file mode 100644 index 000000000..e2c5d6fbd --- /dev/null +++ b/packages/appkit/src/plugins/server/tests/security/error-handler.test.ts @@ -0,0 +1,162 @@ +import express from "express"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { AppKitError } from "../../../../errors/base"; +import { createErrorHandler } from "../../security/error-handler"; + +class TestAppKitError extends AppKitError { + readonly code = "TEST_ERROR"; + readonly statusCode = 422; + readonly isRetryable = false; +} + +function createTestApp( + config?: Parameters[0], + routeSetup?: (app: express.Application) => void, +) { + const app = express(); + app.use(express.json()); + + if (routeSetup) { + routeSetup(app); + } else { + app.get("/throw-appkit", (_req, res, next) => { + next( + new TestAppKitError("Something went wrong", { + context: { userId: "123" }, + }), + ); + }); + app.get("/throw-generic", (_req, res, next) => { + next(new Error("Unexpected failure with secret-token-abc")); + }); + app.post("/parse-json", (req, res) => { + res.json({ received: req.body }); + }); + } + + app.use(createErrorHandler(config)); + return app; +} + +async function request( + app: express.Application, + method: string, + path: string, + options?: { body?: string; headers?: Record }, +) { + const server = app.listen(0); + const addr = server.address() as { port: number }; + try { + const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, { + method, + headers: options?.headers, + body: options?.body, + }); + const body = (await res.json()) as Record; + return { status: res.status, body }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("Error Handler Middleware", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("production mode", () => { + test("AppKitError returns statusCode and code, not message", async () => { + const app = createTestApp(); + const res = await request(app, "GET", "/throw-appkit"); + expect(res.status).toBe(422); + expect(res.body.error).toBe("Internal Server Error"); + expect(res.body.code).toBe("TEST_ERROR"); + expect(res.body.stack).toBeUndefined(); + expect(res.body.message).toBeUndefined(); + }); + + test("AppKitError hides code when includeErrorCode is false", async () => { + const app = createTestApp({ includeErrorCode: false }); + const res = await request(app, "GET", "/throw-appkit"); + expect(res.status).toBe(422); + expect(res.body.code).toBeUndefined(); + }); + + test("generic error returns 500 with no details", async () => { + const app = createTestApp(); + const res = await request(app, "GET", "/throw-generic"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Internal Server Error"); + expect(res.body.stack).toBeUndefined(); + // Should not leak the error message with "secret-token" + expect(JSON.stringify(res.body)).not.toContain("secret-token"); + }); + + test("malformed JSON returns 400 Bad Request", async () => { + const app = createTestApp(); + const res = await request(app, "POST", "/parse-json", { + body: "{invalid json", + headers: { "Content-Type": "application/json" }, + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Bad Request"); + expect(res.body.message).toBeUndefined(); + }); + }); + + describe("dev mode", () => { + test("includes message and stack in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + const app = createTestApp(); + const res = await request(app, "GET", "/throw-generic"); + expect(res.status).toBe(500); + expect(res.body.error).toContain("Unexpected failure"); + expect(res.body.stack).toBeDefined(); + }); + + test("AppKitError includes message in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + const app = createTestApp(); + const res = await request(app, "GET", "/throw-appkit"); + expect(res.status).toBe(422); + expect(res.body.error).toBe("Something went wrong"); + expect(res.body.code).toBe("TEST_ERROR"); + expect(res.body.stack).toBeDefined(); + }); + + test("malformed JSON includes message in dev mode", async () => { + vi.stubEnv("NODE_ENV", "development"); + const app = createTestApp(); + const res = await request(app, "POST", "/parse-json", { + body: "{bad", + headers: { "Content-Type": "application/json" }, + }); + expect(res.status).toBe(400); + expect(res.body.message).toBeDefined(); + }); + }); + + describe("disabled", () => { + test("errorHandler: false passes errors through", async () => { + const app = express(); + app.get("/throw", (_req, _res, next) => { + next(new Error("test")); + }); + app.use(createErrorHandler(false)); + // Express default handler will return HTML 500 + const server = app.listen(0); + const addr = server.address() as { port: number }; + try { + const res = await fetch(`http://127.0.0.1:${addr.port}/throw`); + // Express default error handler returns 500 with HTML + expect(res.status).toBe(500); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + }); +}); diff --git a/packages/appkit/src/plugins/server/tests/security/security.test.ts b/packages/appkit/src/plugins/server/tests/security/security.test.ts new file mode 100644 index 000000000..951cfe5ca --- /dev/null +++ b/packages/appkit/src/plugins/server/tests/security/security.test.ts @@ -0,0 +1,169 @@ +import type { Server } from "node:http"; +import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; +import type { PluginManifest } from "shared"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { ServiceContext } from "../../../../context/service-context"; +import { createApp } from "../../../../core"; +import { Plugin, toPlugin } from "../../../../plugin"; +import { server as serverPlugin } from "../../index"; + +describe("Security Integration", () => { + let server: Server; + let baseUrl: string; + let serviceContextMock: Awaited>; + const TEST_PORT = 9890; + + beforeAll(async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DATABRICKS_APP_URL", "https://my-app.databricksapps.com"); + setupDatabricksEnv(); + ServiceContext.reset(); + serviceContextMock = await mockServiceContext(); + + class TestPlugin extends Plugin { + static manifest = { + name: "test-sec", + displayName: "Test Security Plugin", + description: "Test plugin for security integration tests", + resources: { required: [], optional: [] }, + } satisfies PluginManifest<"test-sec">; + + injectRoutes(router: any) { + router.get("/data", (_req: any, res: any) => { + res.json({ data: "hello" }); + }); + router.post("/data", (req: any, res: any) => { + res.json({ received: req.body }); + }); + } + } + + const testPlugin = toPlugin(TestPlugin); + + const app = await createApp({ + plugins: [ + serverPlugin({ + port: TEST_PORT, + host: "127.0.0.1", + }), + testPlugin({}), + ], + }); + + server = app.server.getServer(); + baseUrl = `http://127.0.0.1:${TEST_PORT}`; + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(async () => { + vi.unstubAllEnvs(); + serviceContextMock?.restore(); + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); + + describe("security headers (Helmet)", () => { + test("sets Content-Security-Policy on responses", async () => { + const res = await fetch(`${baseUrl}/health`); + const csp = res.headers.get("content-security-policy"); + expect(csp).toBeDefined(); + expect(csp).toContain("default-src"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + test("sets X-Content-Type-Options: nosniff", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + test("sets Cross-Origin-Opener-Policy", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.headers.get("cross-origin-opener-policy")).toBe("same-origin"); + }); + + test("sets X-Frame-Options", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.headers.get("x-frame-options")).toBeDefined(); + }); + }); + + describe("CSRF protection", () => { + test("GET requests pass through CSRF", async () => { + const res = await fetch(`${baseUrl}/api/test-sec/data`, { + headers: { origin: "https://evil.com" }, + }); + expect(res.status).toBe(200); + }); + + test("POST without Origin passes (same-origin)", async () => { + const res = await fetch(`${baseUrl}/api/test-sec/data`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: true }), + }); + expect(res.status).toBe(200); + }); + + test("POST with matching origin passes", async () => { + const res = await fetch(`${baseUrl}/api/test-sec/data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://my-app.databricksapps.com", + }, + body: JSON.stringify({ test: true }), + }); + expect(res.status).toBe(200); + }); + + test("POST with evil origin is rejected 403", async () => { + const res = await fetch(`${baseUrl}/api/test-sec/data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://evil.com", + }, + body: JSON.stringify({ test: true }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body.error).toBe("CSRF validation failed"); + }); + + test("POST with Origin: null is rejected 403", async () => { + const res = await fetch(`${baseUrl}/api/test-sec/data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "null", + }, + body: JSON.stringify({ test: true }), + }); + expect(res.status).toBe(403); + }); + }); + + describe("CORS (disabled by default)", () => { + test("no Access-Control headers when CORS is not configured", async () => { + const res = await fetch(`${baseUrl}/health`, { + headers: { Origin: "https://other.com" }, + }); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + }); +}); diff --git a/packages/appkit/src/plugins/server/types.ts b/packages/appkit/src/plugins/server/types.ts index e13b7a78d..5e2f38ee6 100644 --- a/packages/appkit/src/plugins/server/types.ts +++ b/packages/appkit/src/plugins/server/types.ts @@ -1,4 +1,7 @@ import type { BasePluginConfig } from "shared"; +import type { SecurityConfig } from "./security/types"; + +export type { SecurityConfig } from "./security/types"; export interface ServerConfig extends BasePluginConfig { port?: number; @@ -15,4 +18,6 @@ export interface ServerConfig extends BasePluginConfig { * your app routinely posts larger JSON bodies. */ bodyLimit?: string; + /** Security configuration. Secure defaults applied when omitted. */ + security?: SecurityConfig; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c576bd74a..57c4cbbdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + cors: + specifier: ^2.8.6 + version: 2.8.6 dotenv: specifier: 16.6.1 version: 16.6.1 @@ -308,6 +311,9 @@ importers: get-port: specifier: 7.2.0 version: 7.2.0 + helmet: + specifier: ^8.1.0 + version: 8.1.0 js-yaml: specifier: 4.1.1 version: 4.1.1 @@ -339,6 +345,9 @@ importers: '@opentelemetry/context-async-hooks': specifier: 2.6.1 version: 2.6.1(@opentelemetry/api@1.9.0) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 '@types/express': specifier: 4.17.25 version: 4.17.25 @@ -2166,9 +2175,15 @@ packages: resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} engines: {node: '>=20.0'} + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -2670,6 +2685,9 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -4804,6 +4822,9 @@ packages: '@types/conventional-commits-parser@5.0.1': resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -5176,7 +5197,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} @@ -5492,13 +5512,6 @@ packages: autocomplete.js@0.37.1: resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==} - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -6103,6 +6116,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -6676,7 +6693,6 @@ packages: dottie@2.0.6: resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. drizzle-orm@0.45.1: resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} @@ -7264,9 +7280,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -7619,6 +7632,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -9055,10 +9072,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - normalize-url@8.1.0: resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} engines: {node: '>=14.16'} @@ -14449,12 +14462,23 @@ snapshots: - uglify-js - webpack-cli + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -14924,6 +14948,13 @@ snapshots: dependencies: langium: 3.3.1 + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -16654,7 +16685,7 @@ snapshots: '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.0.7 optional: true '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': @@ -17116,6 +17147,10 @@ snapshots: dependencies: '@types/node': 25.2.3 + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.2.3 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -17965,16 +18000,6 @@ snapshots: dependencies: immediate: 3.3.0 - autoprefixer@10.4.21(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001760 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -18676,6 +18701,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -18808,7 +18838,7 @@ snapshots: cssnano-preset-advanced@6.1.2(postcss@8.5.6): dependencies: - autoprefixer: 10.4.21(postcss@8.5.6) + autoprefixer: 10.4.23(postcss@8.5.6) browserslist: 4.28.1 cssnano-preset-default: 6.1.2(postcss@8.5.6) postcss: 8.5.6 @@ -19838,8 +19868,6 @@ snapshots: forwarded@0.2.0: {} - fraction.js@4.3.7: {} - fraction.js@5.3.4: {} fresh@0.5.2: {} @@ -20414,6 +20442,8 @@ snapshots: he@1.2.0: {} + helmet@8.1.0: {} + hermes-estree@0.25.1: {} hermes-estree@0.33.3: @@ -22101,8 +22131,6 @@ snapshots: normalize-path@3.0.0: {} - normalize-range@0.1.2: {} - normalize-url@8.1.0: {} normalize-url@8.1.1: {}