Skip to content

Commit 50dcac0

Browse files
committed
feat: add security middleware to server plugin (CSRF, CSP, CORS, error handler)
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 <pawel.kosiec@databricks.com>
1 parent 4ee02ce commit 50dcac0

12 files changed

Lines changed: 1168 additions & 32 deletions

File tree

packages/appkit/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@
7373
"@opentelemetry/sdk-trace-base": "2.6.0",
7474
"@opentelemetry/semantic-conventions": "1.38.0",
7575
"@types/semver": "7.7.1",
76+
"cors": "^2.8.6",
7677
"dotenv": "16.6.1",
7778
"express": "4.22.0",
7879
"get-port": "7.2.0",
80+
"helmet": "^8.1.0",
7981
"js-yaml": "4.1.1",
8082
"obug": "2.1.1",
8183
"pg": "8.18.0",
@@ -88,6 +90,7 @@
8890
},
8991
"devDependencies": {
9092
"@opentelemetry/context-async-hooks": "2.6.1",
93+
"@types/cors": "^2.8.19",
9194
"@types/express": "4.17.25",
9295
"@types/js-yaml": "4.0.9",
9396
"@types/json-schema": "7.0.15",

packages/appkit/src/plugins/server/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { instrumentations } from "../../telemetry";
1414
import { sanitizeClientConfig } from "./client-config-sanitizer";
1515
import manifest from "./manifest.json";
1616
import { RemoteTunnelController } from "./remote-tunnel/remote-tunnel-controller";
17+
import { registerErrorHandler, registerSecurityMiddleware } from "./security";
1718
import { StaticServer } from "./static-server";
1819
import type { ServerConfig } from "./types";
1920
import { getRoutes, type PluginEndpoints, printRoutes } from "./utils";
@@ -111,6 +112,10 @@ export class ServerPlugin extends Plugin {
111112
*/
112113
async start(): Promise<express.Application> {
113114
this.serverApplication.use(requestMetricsMiddleware);
115+
116+
// Security middleware first — inspects headers only, no body needed
117+
registerSecurityMiddleware(this.serverApplication, this.config.security);
118+
114119
this.serverApplication.use(
115120
express.json({
116121
// Express's stock 100kb default is too tight for modern apps —
@@ -147,6 +152,9 @@ export class ServerPlugin extends Plugin {
147152

148153
await this.setupFrontend(endpoints, pluginConfigs);
149154

155+
// Error handler last — catches unhandled errors from API routes
156+
registerErrorHandler(this.serverApplication, this.config.security);
157+
150158
const listenPort = await this.resolveListenPort();
151159

152160
const server = this.serverApplication.listen(

packages/appkit/src/plugins/server/manifest.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,75 @@
2424
"staticPath": {
2525
"type": "string",
2626
"description": "Path to static files directory (auto-detected if not provided)"
27+
},
28+
"bodyLimit": {
29+
"type": "string",
30+
"description": "JSON body size limit (e.g. '100kb', '1mb'). Default: '100kb'"
31+
},
32+
"security": {
33+
"type": "object",
34+
"description": "Security configuration. Secure defaults applied when omitted.",
35+
"properties": {
36+
"csrf": {
37+
"oneOf": [
38+
{
39+
"type": "object",
40+
"properties": {
41+
"allowedOrigins": {
42+
"type": "array",
43+
"items": { "type": "string" },
44+
"description": "Additional trusted origins for CSRF validation"
45+
}
46+
}
47+
},
48+
{ "const": false }
49+
]
50+
},
51+
"helmet": {
52+
"oneOf": [
53+
{
54+
"type": "object",
55+
"description": "HelmetOptions — fully replaces defaults"
56+
},
57+
{ "const": false }
58+
]
59+
},
60+
"cors": {
61+
"oneOf": [
62+
{
63+
"type": "object",
64+
"properties": {
65+
"allowedOrigins": {
66+
"type": "array",
67+
"items": { "type": "string" }
68+
},
69+
"credentials": { "type": "boolean" },
70+
"maxAge": { "type": "number" },
71+
"allowedMethods": {
72+
"type": "array",
73+
"items": { "type": "string" }
74+
},
75+
"allowedHeaders": {
76+
"type": "array",
77+
"items": { "type": "string" }
78+
}
79+
}
80+
},
81+
{ "const": false }
82+
]
83+
},
84+
"errorHandler": {
85+
"oneOf": [
86+
{
87+
"type": "object",
88+
"properties": {
89+
"includeErrorCode": { "type": "boolean" }
90+
}
91+
},
92+
{ "const": false }
93+
]
94+
}
95+
}
2796
}
2897
}
2998
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import { createLogger } from "../../../logging/logger";
3+
import type { CsrfConfig } from "./types";
4+
5+
const logger = createLogger("server");
6+
7+
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
8+
9+
/**
10+
* Parse a comma-separated env var into trimmed, non-empty strings.
11+
*/
12+
function parseEnvOrigins(envVar: string | undefined): string[] {
13+
if (!envVar) return [];
14+
return envVar
15+
.split(",")
16+
.map((s) => s.trim())
17+
.filter(Boolean);
18+
}
19+
20+
/**
21+
* Build the set of trusted origins from all sources:
22+
* 1. DATABRICKS_APP_URL env var
23+
* 2. Config allowedOrigins
24+
* 3. APPKIT_CSRF_ALLOWED_ORIGINS env var
25+
*/
26+
function buildTrustedOrigins(config?: CsrfConfig): Set<string> {
27+
const origins = new Set<string>();
28+
29+
const appUrl = process.env.DATABRICKS_APP_URL;
30+
if (appUrl) {
31+
try {
32+
origins.add(new URL(appUrl).origin.toLowerCase());
33+
} catch {
34+
logger.warn(
35+
"DATABRICKS_APP_URL is not a valid URL: %s — skipping for CSRF",
36+
appUrl,
37+
);
38+
}
39+
}
40+
41+
for (const o of config?.allowedOrigins ?? []) {
42+
try {
43+
origins.add(new URL(o).origin.toLowerCase());
44+
} catch {
45+
logger.warn(
46+
"CSRF allowedOrigins entry is not a valid URL: %s — skipping",
47+
o,
48+
);
49+
}
50+
}
51+
52+
for (const o of parseEnvOrigins(process.env.APPKIT_CSRF_ALLOWED_ORIGINS)) {
53+
origins.add(o.toLowerCase().replace(/\/$/, ""));
54+
}
55+
56+
return origins;
57+
}
58+
59+
/**
60+
* Check if an origin matches localhost (any port).
61+
*/
62+
function isLocalhostOrigin(origin: string): boolean {
63+
try {
64+
const url = new URL(origin);
65+
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
66+
} catch {
67+
return false;
68+
}
69+
}
70+
71+
/**
72+
* Same-origin heuristic: compare Origin against Host header.
73+
* Used as fallback when no trusted origins are configured.
74+
*/
75+
function isSameOrigin(origin: string, req: Request): boolean {
76+
const host = req.headers.host;
77+
if (!host) return false;
78+
79+
try {
80+
const originUrl = new URL(origin);
81+
const originHost = originUrl.host.toLowerCase();
82+
return originHost === host.toLowerCase();
83+
} catch {
84+
return false;
85+
}
86+
}
87+
88+
/**
89+
* Create CSRF protection middleware using Origin header validation.
90+
*
91+
* - Applies to state-changing methods (POST, PUT, DELETE, PATCH) only
92+
* - Allows absent/empty Origin (same-origin browser or non-browser client)
93+
* - Rejects `Origin: null` (sandboxed iframe attack vector)
94+
* - In dev mode, auto-allows localhost origins
95+
* - Falls back to Host header comparison when no trusted origins are configured
96+
*/
97+
export function createCsrfMiddleware(
98+
config?: CsrfConfig | false,
99+
): (req: Request, res: Response, next: NextFunction) => void {
100+
if (config === false) {
101+
return (_req, _res, next) => next();
102+
}
103+
104+
const isDev = process.env.NODE_ENV === "development";
105+
const trustedOrigins = buildTrustedOrigins(
106+
config === undefined ? undefined : config,
107+
);
108+
109+
if (!isDev && trustedOrigins.size === 0) {
110+
logger.warn(
111+
"DATABRICKS_APP_URL not set and no CSRF origins configured — CSRF will use Host header fallback. Set DATABRICKS_APP_URL for full protection.",
112+
);
113+
}
114+
115+
return (req: Request, res: Response, next: NextFunction) => {
116+
if (!STATE_CHANGING_METHODS.has(req.method)) {
117+
return next();
118+
}
119+
120+
const origin = req.headers.origin;
121+
122+
// No Origin header — allow (same-origin or non-browser client)
123+
if (!origin || origin === "") {
124+
return next();
125+
}
126+
127+
// Reject Origin: null (sandboxed iframe, data: URI)
128+
if (origin === "null") {
129+
logger.debug("CSRF rejected: null Origin on %s %s", req.method, req.path);
130+
return res.status(403).json(
131+
isDev
132+
? {
133+
error: "CSRF validation failed",
134+
detail:
135+
"Origin: null rejected — possible sandboxed iframe or data: URI",
136+
}
137+
: { error: "CSRF validation failed" },
138+
);
139+
}
140+
141+
const normalizedOrigin = origin.toLowerCase().replace(/\/$/, "");
142+
143+
// In dev mode, allow localhost origins
144+
if (isDev && isLocalhostOrigin(normalizedOrigin)) {
145+
return next();
146+
}
147+
148+
// In production, reject non-HTTPS origins
149+
if (!isDev && !normalizedOrigin.startsWith("https://")) {
150+
logger.debug(
151+
"CSRF rejected: non-HTTPS Origin %s on %s %s",
152+
origin,
153+
req.method,
154+
req.path,
155+
);
156+
return res.status(403).json(
157+
isDev
158+
? {
159+
error: "CSRF validation failed",
160+
detail: `Origin must use HTTPS in production: ${origin}`,
161+
}
162+
: { error: "CSRF validation failed" },
163+
);
164+
}
165+
166+
// Check against trusted origins
167+
if (trustedOrigins.has(normalizedOrigin)) {
168+
return next();
169+
}
170+
171+
// Fallback: same-origin heuristic (compare Origin vs Host)
172+
if (trustedOrigins.size === 0 && isSameOrigin(origin, req)) {
173+
return next();
174+
}
175+
176+
logger.debug(
177+
"CSRF rejected: Origin %s not trusted on %s %s",
178+
origin,
179+
req.method,
180+
req.path,
181+
);
182+
return res.status(403).json(
183+
isDev
184+
? {
185+
error: "CSRF validation failed",
186+
detail: `Origin ${origin} not in trusted set`,
187+
}
188+
: { error: "CSRF validation failed" },
189+
);
190+
};
191+
}

0 commit comments

Comments
 (0)