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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
],
"workspaces": {
"packages/appkit": {},
"packages/appkit-ui": {}
"packages/appkit-ui": {
"ignoreDependencies": ["react-dom", "@types/react-dom"]
}
},
"ignore": [
"**/*.generated.ts",
Expand Down
3 changes: 3 additions & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@
"@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",
"helmet": "^8.1.0",
"obug": "2.1.1",
"pg": "8.18.0",
"picocolors": "1.1.1",
Expand All @@ -79,6 +81,7 @@
"ws": "8.18.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "4.17.25",
"@types/json-schema": "7.0.15",
"@types/pg": "8.16.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/appkit/src/plugins/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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";
Expand Down Expand Up @@ -93,8 +94,12 @@ export class ServerPlugin extends Plugin {
* @returns The express application.
*/
async start(): Promise<express.Application> {
// Security middleware first — inspects headers only, no body needed
registerSecurityMiddleware(this.serverApplication, this.config.security);

this.serverApplication.use(
express.json({
limit: this.config.bodyLimit ?? "100kb",
type: (req) => {
// Skip JSON parsing for routes that declared skipBodyParsing
// (e.g. file uploads where the raw body must flow through).
Expand Down Expand Up @@ -122,6 +127,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 server = this.serverApplication.listen(
this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port,
this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host,
Expand Down
69 changes: 69 additions & 0 deletions packages/appkit/src/plugins/server/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 }
]
}
}
}
}
}
Expand Down
184 changes: 184 additions & 0 deletions packages/appkit/src/plugins/server/security/csrf.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const origins = new Set<string>();

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" },
);
};
}
Loading
Loading