Skip to content

Commit 31f5bd3

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 31f5bd3

4 files changed

Lines changed: 183 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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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-src": "'self' https://www.youtube.com", // Allow self-iframes and YouTube embeds
35+
"frame-ancestors": "'none'", // Prevents clickjacking
36+
"base-uri": "'self'",
37+
"form-action": "'self'",
38+
"object-src": "'none'",
39+
"upgrade-insecure-requests": true,
40+
};
41+
42+
const mergedCsp = { ...baseCsp, ...customCsp };
43+
44+
// Convert to CSP string
45+
const cspString = Object.entries(mergedCsp)
46+
.map(([directive, value]) => {
47+
if (typeof value === "boolean") {
48+
return value ? directive : "";
49+
}
50+
const valueStr = Array.isArray(value) ? value.join(" ") : value;
51+
return `${directive} ${valueStr}`;
52+
})
53+
.filter(Boolean)
54+
.join("; ");
55+
56+
const headers: Record<string, string> = {
57+
"X-Frame-Options": "DENY", // Clickjacking protection
58+
"Content-Security-Policy": cspString,
59+
"X-Content-Type-Options": "nosniff", // MIME protection
60+
"Referrer-Policy": "strict-origin-when-cross-origin",
61+
"X-XSS-Protection": "1; mode=block", // Legacy XSS protection
62+
};
63+
64+
// Add HSTS in production only
65+
if (enforceHttps && environment === "production") {
66+
headers["Strict-Transport-Security"] =
67+
"max-age=31536000; includeSubDomains; preload";
68+
}
69+
70+
return headers;
71+
}
72+
73+
/** Apply security headers to Express response */
74+
export function applySecurityHeaders(
75+
res: { set: (headers: Record<string, string>) => void },
76+
config?: SecurityHeadersConfig
77+
): void {
78+
const headers = getSecurityHeaders(config);
79+
res.set(headers);
80+
}
81+
82+
/** Apply security headers to Web API Response */
83+
export function applySecurityHeadersToResponse(
84+
response: Response,
85+
config?: SecurityHeadersConfig
86+
): Response {
87+
const headers = getSecurityHeaders(config);
88+
89+
const newHeaders = new Headers(response.headers);
90+
Object.entries(headers).forEach(([key, value]) => {
91+
newHeaders.set(key, value);
92+
});
93+
94+
return new Response(response.body, {
95+
status: response.status,
96+
statusText: response.statusText,
97+
headers: newHeaders,
98+
});
99+
}
100+
101+
/** Check if response should have security headers (HTML and error responses) */
102+
export function shouldApplySecurityHeaders(
103+
contentType?: string | null,
104+
statusCode?: number
105+
): boolean {
106+
if (!contentType && statusCode && statusCode >= 400) {
107+
return true; // Error responses
108+
}
109+
110+
if (contentType?.includes("text/html")) {
111+
return true;
112+
}
113+
114+
return false;
115+
}

0 commit comments

Comments
 (0)