Skip to content

Commit 360c08c

Browse files
committed
feat(security): add security headers to all responses
Reported by the 408 Solutions Security Team (https://www.408solutions.com).
1 parent b6a908f commit 360c08c

4 files changed

Lines changed: 182 additions & 7 deletions

File tree

apps/api/src/routes/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { GetNodeTypesResponse, WorkflowType } from "@dafthunk/types";
22
import { Hono } from "hono";
33

4+
import { optionalJwtMiddleware } from "../auth";
45
import { ApiContext } from "../context";
56
import { CloudflareNodeRegistry } from "../nodes/cloudflare-node-registry";
6-
import { optionalJwtMiddleware } from "../auth";
77

88
const typeRoutes = new Hono<ApiContext>();
99

apps/web/functions/[[path]].ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import { initializeApiBaseUrl } from "../src/config/api";
22
import { render } from "../src/entry-server";
3+
import {
4+
applySecurityHeadersToResponse,
5+
shouldApplySecurityHeaders,
6+
} from "../src/utils/security-headers";
37

48
interface Env {
59
ASSETS: Fetcher;
610
VITE_API_HOST?: string;
711
}
812

13+
/**
14+
* Apply security headers to responses
15+
*/
16+
function addSecurityHeaders(response: Response): Response {
17+
const contentType = response.headers.get("content-type");
18+
if (shouldApplySecurityHeaders(contentType, response.status)) {
19+
return applySecurityHeadersToResponse(response, {
20+
environment: "production",
21+
enforceHttps: true,
22+
});
23+
}
24+
return response;
25+
}
26+
927
export const onRequest: PagesFunction<Env> = async (context) => {
1028
const { request, env } = context;
1129
initializeApiBaseUrl(env.VITE_API_HOST);
@@ -36,13 +54,15 @@ export const onRequest: PagesFunction<Env> = async (context) => {
3654
.replace(`<!--app-head-->`, rendered.headHtml ?? "")
3755
.replace(`<!--app-html-->`, "");
3856

39-
return new Response(html, {
57+
const response = new Response(html, {
4058
headers: { "Content-Type": "text/html" },
4159
status: 200,
4260
});
61+
62+
return addSecurityHeaders(response);
4363
} catch (e: any) {
4464
if (e instanceof Response) {
45-
return e;
65+
return addSecurityHeaders(e);
4666
}
4767
console.error("SSR Function Error:", e.stack || e);
4868
try {
@@ -55,9 +75,14 @@ export const onRequest: PagesFunction<Env> = async (context) => {
5575
} catch (fetchError) {
5676
console.error("SSR Fallback ASSETS.fetch error:", fetchError);
5777
}
58-
return new Response("Internal Server Error. Please try again later.", {
59-
status: 500,
60-
headers: { "Content-Type": "text/html" },
61-
});
78+
const errorResponse = new Response(
79+
"Internal Server Error. Please try again later.",
80+
{
81+
status: 500,
82+
headers: { "Content-Type": "text/html" },
83+
}
84+
);
85+
86+
return addSecurityHeaders(errorResponse);
6287
}
6388
};

apps/web/server.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,45 @@ import fs from "node:fs/promises";
33
import express from "express";
44
import { createServer } from "vite";
55

6+
import {
7+
applySecurityHeaders,
8+
shouldApplySecurityHeaders,
9+
} from "./src/utils/security-headers";
10+
611
const port = process.env.PORT || 3000;
712
const app = express();
813

14+
/**
15+
* Middleware to add security headers to responses
16+
*/
17+
app.use((_, res, next) => {
18+
const originalJson = res.json;
19+
const originalSend = res.send;
20+
21+
res.send = function (body) {
22+
addSecurityHeaders(this);
23+
return originalSend.call(this, body);
24+
};
25+
26+
res.json = function (obj) {
27+
addSecurityHeaders(this);
28+
return originalJson.call(this, obj);
29+
};
30+
31+
function addSecurityHeaders(response: express.Response) {
32+
const contentType = response.get("Content-Type");
33+
if (shouldApplySecurityHeaders(contentType, response.statusCode)) {
34+
applySecurityHeaders(response, {
35+
environment:
36+
process.env.NODE_ENV === "production" ? "production" : "development",
37+
enforceHttps: process.env.NODE_ENV === "production",
38+
});
39+
}
40+
}
41+
42+
next();
43+
});
44+
945
const vite = await createServer({
1046
server: { middlewareMode: true },
1147
appType: "custom",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Security headers configuration
3+
*/
4+
export interface SecurityHeadersConfig {
5+
/** Enforce HTTPS via HSTS */
6+
enforceHttps?: boolean;
7+
/** Custom CSP directives */
8+
customCsp?: Record<string, string | string[]>;
9+
/** Environment mode */
10+
environment?: "development" | "production";
11+
}
12+
13+
/**
14+
* Get security headers for HTTP responses
15+
* Protects against clickjacking, XSS, MIME confusion, and other attacks
16+
*/
17+
export function getSecurityHeaders(
18+
config: SecurityHeadersConfig = {}
19+
): Record<string, string> {
20+
const {
21+
enforceHttps = true,
22+
customCsp = {},
23+
environment = "production",
24+
} = config;
25+
26+
// Base CSP configuration
27+
const baseCsp = {
28+
"default-src": "'self'",
29+
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com", // React/Vite compatibility + Cloudflare Insights
30+
"style-src": "'self' 'unsafe-inline'", // Tailwind/CSS-in-JS support
31+
"img-src": "'self' data: https:",
32+
"font-src": "'self' data:",
33+
"connect-src": "'self' https://api.dafthunk.com", // Allow API connections
34+
"frame-ancestors": "'none'", // Prevents clickjacking
35+
"base-uri": "'self'",
36+
"form-action": "'self'",
37+
"object-src": "'none'",
38+
"upgrade-insecure-requests": true,
39+
};
40+
41+
const mergedCsp = { ...baseCsp, ...customCsp };
42+
43+
// Convert to CSP string
44+
const cspString = Object.entries(mergedCsp)
45+
.map(([directive, value]) => {
46+
if (typeof value === "boolean") {
47+
return value ? directive : "";
48+
}
49+
const valueStr = Array.isArray(value) ? value.join(" ") : value;
50+
return `${directive} ${valueStr}`;
51+
})
52+
.filter(Boolean)
53+
.join("; ");
54+
55+
const headers: Record<string, string> = {
56+
"X-Frame-Options": "DENY", // Clickjacking protection
57+
"Content-Security-Policy": cspString,
58+
"X-Content-Type-Options": "nosniff", // MIME protection
59+
"Referrer-Policy": "strict-origin-when-cross-origin",
60+
"X-XSS-Protection": "1; mode=block", // Legacy XSS protection
61+
};
62+
63+
// Add HSTS in production only
64+
if (enforceHttps && environment === "production") {
65+
headers["Strict-Transport-Security"] =
66+
"max-age=31536000; includeSubDomains; preload";
67+
}
68+
69+
return headers;
70+
}
71+
72+
/** Apply security headers to Express response */
73+
export function applySecurityHeaders(
74+
res: { set: (headers: Record<string, string>) => void },
75+
config?: SecurityHeadersConfig
76+
): void {
77+
const headers = getSecurityHeaders(config);
78+
res.set(headers);
79+
}
80+
81+
/** Apply security headers to Web API Response */
82+
export function applySecurityHeadersToResponse(
83+
response: Response,
84+
config?: SecurityHeadersConfig
85+
): Response {
86+
const headers = getSecurityHeaders(config);
87+
88+
const newHeaders = new Headers(response.headers);
89+
Object.entries(headers).forEach(([key, value]) => {
90+
newHeaders.set(key, value);
91+
});
92+
93+
return new Response(response.body, {
94+
status: response.status,
95+
statusText: response.statusText,
96+
headers: newHeaders,
97+
});
98+
}
99+
100+
/** Check if response should have security headers (HTML and error responses) */
101+
export function shouldApplySecurityHeaders(
102+
contentType?: string | null,
103+
statusCode?: number
104+
): boolean {
105+
if (!contentType && statusCode && statusCode >= 400) {
106+
return true; // Error responses
107+
}
108+
109+
if (contentType?.includes("text/html")) {
110+
return true;
111+
}
112+
113+
return false;
114+
}

0 commit comments

Comments
 (0)