import type { Elysia } from 'elysia';
/**
* Security Headers Middleware
*
* Adds the response headers a browser needs to constrain what a Clopen page is
* allowed to do. Each directive below is set to what the app actually uses —
* see the notes on the loose ones, which are loose for a reason rather than by
* oversight.
*/
/**
* `@monaco-editor/loader` fetches the editor at runtime from jsDelivr; nothing
* calls `loader.config()` to point it at a local copy, so the code editor, diff
* viewer, conflict resolver and SQL console all load their scripts, stylesheet
* and codicon font cross-origin. Monaco then boots its language workers from a
* `blob:` that `importScripts()` the same host, which is why `blob:` appears in
* `script-src` and `worker-src` too. Bundling Monaco locally would let every
* one of these entries go.
*/
const MONACO_CDN = 'https://cdn.jsdelivr.net';
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
// No tag and no cross-origin form target in the app, so these cost
// nothing and close two common ways an injected tag redirects a page.
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
// Clopen is never meant to be embedded. Paired with X-Frame-Options below
// for browsers that predate frame-ancestors.
"frame-ancestors 'none'",
// 'unsafe-inline' and 'unsafe-eval' are what Monaco's AMD loader needs. They
// blunt the XSS value of script-src considerably — the origin allowlist is
// what this directive still buys us.
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ${MONACO_CDN}`,
`style-src 'self' 'unsafe-inline' ${MONACO_CDN}`,
`font-src 'self' data: ${MONACO_CDN}`,
// Markdown renders whatever image URL the author wrote, including remote
// ones from agent output and project READMEs. Narrowing this would silently
// break those, so images stay open and the tighter directives above carry
// the weight.
"img-src 'self' data: blob: https: http:",
// Media previews, notification sounds and the PDF viewer all read from
// object URLs built in the browser.
"media-src 'self' blob:",
"frame-src 'self' blob:",
"worker-src 'self' blob:",
"child-src 'self' blob:",
// The WebSocket is always same-origin (`frontend/utils/ws.ts` builds it from
// window.location), which 'self' covers under CSP3.
`connect-src 'self' blob: data: ${MONACO_CDN}`,
"manifest-src 'self'"
].join('; ');
/**
* Browser features are granted to the app itself, not to third parties. The
* Preview Browser relays a page's device requests to the viewer's own browser
* (`frontend/services/preview/browser/host-bridge.service.ts`), so camera,
* microphone, geolocation, clipboard and screen capture have to stay
* available; everything Clopen never touches is denied outright.
*/
const PERMISSIONS_POLICY = [
'camera=(self)',
'microphone=(self)',
'geolocation=(self)',
'display-capture=(self)',
'clipboard-read=(self)',
'clipboard-write=(self)',
'fullscreen=(self)',
'accelerometer=()',
'ambient-light-sensor=()',
'bluetooth=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'local-fonts=()',
'magnetometer=()',
'midi=()',
'payment=()',
'serial=()',
'usb=()',
'xr-spatial-tracking=()'
].join(', ');
export const SECURITY_HEADERS: Record = {
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
// Every asset the static handler serves gets an explicit Content-Type from
// Bun.file().type, so refusing to sniff can't strand one of them.
'X-Content-Type-Options': 'nosniff',
// frame-ancestors already covers this on current browsers; kept for older
// ones that only understand the legacy header.
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': PERMISSIONS_POLICY
};
export function securityMiddleware(app: Elysia) {
return app.onAfterHandle(({ set }) => {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
set.headers[name] = value;
}
});
}