Skip to content

Commit 95f9cbd

Browse files
committed
feat: Add CSP headers to frontend (#632)
1 parent 10f827c commit 95f9cbd

3 files changed

Lines changed: 50 additions & 44 deletions

File tree

backend/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ app.use(
109109
app.use(compression({ threshold: 1024 }));
110110

111111
const bodySizeLimit = process.env.MAX_BODY_SIZE || "16kb";
112-
app.use(express.json({ limit: bodySizeLimit }));
112+
app.use(express.json({ limit: bodySizeLimit, type: ['application/json', 'application/csp-report'] }));
113113

114114
// OpenAPI documentation endpoints (public, not rate-limited or cached)
115115
const openApiDocument = generateOpenApiDocument();
@@ -644,6 +644,11 @@ app.get('/api/open-issues', async (_req: Request, res: Response) => {
644644
res.json({ data });
645645
});
646646

647+
app.post('/api/csp-report', (req: Request, res: Response) => {
648+
logInfo('csp_violation', { report: req.body }, config.logLevel);
649+
res.status(204).end();
650+
});
651+
647652
app.get('/api/config', (_req: Request, res: Response) => {
648653
res.json({
649654
data: {

frontend/vite.config.mts

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,60 +4,53 @@ import { VitePWA } from 'vite-plugin-pwa';
44

55
const isAnalyze = process.env.ANALYZE === 'true';
66

7+
import crypto from 'crypto';
8+
79
/**
8-
* Custom Vite plugin that injects a Content-Security-Policy meta tag into
9-
* the HTML <head>. Uses Report-Only mode so violations are logged to the
10-
* browser console without blocking resources.
11-
*
12-
* Dev mode relaxes script-src (inline scripts for HMR) and connect-src
13-
* (WebSocket for hot-reload). Production uses a strict policy.
14-
*
15-
* To switch from report-only to enforcement, change the meta tag's
16-
* http-equiv from "Content-Security-Policy-Report-Only" to
17-
* "Content-Security-Policy".
10+
* Custom Vite plugin that sets Content-Security-Policy header during development.
11+
* It generates a unique nonce per request for inline scripts.
12+
* In production build, it injects a placeholder for the nonce that Nginx replaces.
1813
*/
19-
function cspMetaTagPlugin(): Plugin {
14+
function cspPlugin(): Plugin {
2015
return {
21-
name: 'html-csp-meta-tag',
16+
name: 'csp-plugin',
17+
configureServer(server) {
18+
server.middlewares.use((req, res, next) => {
19+
const nonce = crypto.randomBytes(16).toString('base64');
20+
(req as any).cspNonce = nonce;
21+
22+
const scriptSrc = "'self' 'nonce-" + nonce + "' 'strict-dynamic'";
23+
const connectSrc = "'self' https://soroban-testnet.stellar.org ws: wss:";
24+
25+
const directives = [
26+
"default-src 'none'",
27+
`script-src ${scriptSrc}`,
28+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
29+
"font-src 'self' https://fonts.gstatic.com",
30+
"img-src 'self' https: data:",
31+
`connect-src ${connectSrc}`,
32+
"frame-src 'none'",
33+
"object-src 'none'",
34+
"base-uri 'self'",
35+
"form-action 'self'",
36+
"report-uri /api/csp-report"
37+
].join('; ');
38+
39+
res.setHeader('Content-Security-Policy', directives);
40+
next();
41+
});
42+
},
2243
transformIndexHtml(html, ctx) {
23-
const isDev = ctx.server != null;
24-
25-
const scriptSrc = isDev
26-
? "'self' 'unsafe-inline'"
27-
: "'self'";
28-
29-
const connectSrc = isDev
30-
? "'self' https://soroban-testnet.stellar.org ws:"
31-
: "'self' https://soroban-testnet.stellar.org";
32-
33-
const directives = [
34-
"default-src 'none'",
35-
`script-src ${scriptSrc}`,
36-
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
37-
"font-src 'self' https://fonts.gstatic.com",
38-
"img-src 'self' https: data:",
39-
`connect-src ${connectSrc}`,
40-
"frame-src 'none'",
41-
"object-src 'none'",
42-
"base-uri 'self'",
43-
"form-action 'self'",
44-
].join('; ');
45-
46-
const metaTag =
47-
`<meta http-equiv="Content-Security-Policy-Report-Only" content="${directives}">`;
48-
49-
return html.replace(
50-
'<meta charset="UTF-8" />',
51-
`<meta charset="UTF-8" />\n ${metaTag}`,
52-
);
44+
const nonce = (ctx.req as any)?.cspNonce || '__CSP_NONCE__';
45+
return html.replace(/<script(\s|>)/g, `<script nonce="${nonce}"$1`);
5346
},
5447
};
5548
}
5649

5750
export default defineConfig(async () => {
5851
const plugins = [
5952
react(),
60-
cspMetaTagPlugin(),
53+
cspPlugin(),
6154
VitePWA({
6255
registerType: 'autoUpdate',
6356
strategies: 'injectManifest',

nginx/conf.d/default.conf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ server {
4949
}
5050

5151
location / {
52+
set $csp_nonce $request_id;
53+
add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'nonce-$csp_nonce' 'strict-dynamic'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https: data:; connect-src 'self' https://soroban-testnet.stellar.org ws: wss:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /api/csp-report" always;
54+
55+
proxy_set_header Accept-Encoding ""; # Prevent upstream compression for sub_filter to work
56+
sub_filter '__CSP_NONCE__' $csp_nonce;
57+
sub_filter_once off;
58+
sub_filter_types text/html;
59+
5260
proxy_pass http://stellar_frontend;
5361
}
5462
}

0 commit comments

Comments
 (0)