Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
},
"dependencies": {
"@clickhouse/click-ui": "0.2.0-rc.4",
"@librechat/data-schemas": "^0.0.48",
"@librechat/data-schemas": "^0.0.52",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query": "5.95.2",
Expand Down
45 changes: 43 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,43 @@ function getCacheHeaders(filePath: string): Record<string, string> {
return {};
}

// 'unsafe-inline' in style-src is required because Tailwind 4 + click-ui inject inline styles at runtime.
// TanStack Start's SSR injects an inline `<script type="module">import("/_build/...")</script>` to
// boot the client. Without a nonce or 'unsafe-inline' for script-src, browsers will block hydration.
// Threading a per-request nonce through TanStack Start's manifest is non-trivial; until that wiring
// lands we ship the policy as report-only so it surfaces violations in dev tooling without breaking
// hydration in prod. Set ADMIN_PANEL_CSP_ENFORCE=true to switch back to enforcement (only safe once
// the nonce path is in place).
const CSP_VALUE = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');

const CSP_ENFORCE = process.env.ADMIN_PANEL_CSP_ENFORCE === 'true';
const CSP_HEADER_NAME = CSP_ENFORCE
? 'Content-Security-Policy'
: 'Content-Security-Policy-Report-Only';

function applySecurityHeaders(headers: Headers): void {
const contentType = headers.get('Content-Type') ?? '';
if (!contentType.toLowerCase().startsWith('text/html')) return;
headers.set(CSP_HEADER_NAME, CSP_VALUE);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('X-Frame-Options', 'DENY');
if (process.env.NODE_ENV === 'production') {
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
}

type Handler = { default: { fetch: (req: Request) => Promise<Response> } };

const { default: handler } = (await import(SERVER_ENTRY.href)) as Handler;
Expand All @@ -41,8 +78,11 @@ async function buildStaticRoutes(): Promise<Record<string, () => Response>> {
for await (const path of new Glob('**/*').scan(CLIENT_DIR)) {
const file = Bun.file(`${CLIENT_DIR}/${path}`);
const cache = getCacheHeaders(path);
routes[`/${path}`] = () =>
new Response(file, { headers: { 'Content-Type': file.type, ...cache } });
routes[`/${path}`] = () => {
const res = new Response(file, { headers: { 'Content-Type': file.type, ...cache } });
applySecurityHeaders(res.headers);
return res;
};
}
return routes;
}
Expand All @@ -63,6 +103,7 @@ const server = Bun.serve({
for (const [k, v] of Object.entries(NO_CACHE)) {
patched.headers.set(k, v);
}
applySecurityHeaders(patched.headers);
return patched;
},
},
Expand Down
Loading
Loading