basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6import * as path from 'node:path';7import express from 'express';8import { writeStderrLine } from '../utils/stdioHelpers.js';9import { isServeDebugMode } from './debug-mode.js';10export { resolveWebShellDir } from './web-shell-resolver.js';11/**12 * Content-Security-Policy for the Web Shell HTML shell.13 *14 * Deliberately looser than the `/demo` page's `default-src 'none'`: the real15 * UI loads same-origin module scripts plus the inline performance.measure16 * patch baked into `index.html`, runs shiki/mermaid (eval + wasm + blob17 * workers), pulls katex fonts/images as `data:`, and streams SSE18 * (`connect-src 'self'`). `frame-ancestors 'none'` + `X-Frame-Options: DENY`19 * still block clickjacking. Tightening `script-src` (drop `'unsafe-inline'`20 * via a hash, externalise the inline patch) is a follow-up, not a blocker for21 * a loopback-default local tool.22 */23const WEB_SHELL_CSP_DIRECTIVES = [24 "default-src 'self'",25 "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",26 "style-src 'self' 'unsafe-inline'",27 "font-src 'self' data:",28 "img-src 'self' data: blob:",29 "connect-src 'self'",30 "worker-src 'self' blob:",31 // base-uri does NOT fall back to default-src; lock it so an injected <base>32 // (the SPA renders AI-generated markdown) cannot repoint relative URLs to an33 // attacker origin.34 "base-uri 'none'",35];36/**37 * Build the Web Shell CSP. `frame-ancestors` defaults to `'none'` (the caller38 * also sets `X-Frame-Options: DENY`) to block clickjacking. When the daemon is39 * started with `--allow-origin chrome-extension://<id>`, those extension40 * origins are allowed to frame the shell so the extension can host the UI in a41 * Chrome side panel (issue #5626); X-Frame-Options is dropped in that case42 * since it can't express an allowlist.43 */44export function buildWebShellCsp(frameAncestors = []) {45 const fa = frameAncestors.length46 ? `frame-ancestors ${frameAncestors.join(' ')}`47 : "frame-ancestors 'none'";48 return [...WEB_SHELL_CSP_DIRECTIVES, fa].join('; ');49}50/** Default (no-framing) Web Shell CSP. */51export const WEB_SHELL_CSP = buildWebShellCsp();52/**53 * True when the request is a top-level document navigation (address-bar54 * load, link click, or refresh) rather than a programmatic fetch/XHR.55 *56 * Mirrors the `bypass` discriminator in `packages/web-shell/vite.config.ts`57 * so the daemon's SPA fallback claims exactly the requests the dev proxy58 * would have served `index.html` for — and leaves API fetches (which carry59 * `Accept: application/json`) to fall through to the JSON routes / 404.60 */61export function isDocumentNavigation(req) {62 const fetchMode = req.headers['sec-fetch-mode'];63 const fetchDest = req.headers['sec-fetch-dest'];64 const accept = req.headers.accept ?? '';65 return (fetchMode === 'navigate' ||66 fetchDest === 'document' ||67 accept.trim().toLowerCase().startsWith('text/html'));68}69/**70 * Build the `index.html` responder for a Web Shell dir. Sets the security71 * headers + a no-cache policy (a redeploy changes the hashed asset names72 * index.html references, so a stale shell would point at missing chunks; the73 * asset files themselves are immutable).74 */75function createSendIndex(webShellDir, frameAncestors = []) {76 const indexPath = path.join(webShellDir, 'index.html');77 const csp = buildWebShellCsp(frameAncestors);78 return (res) => {79 res80 .status(200)81 .set('Content-Security-Policy', csp)82 .set('X-Content-Type-Options', 'nosniff')83 .set('Referrer-Policy', 'no-referrer')84 .set(85 // `microphone=(self)` lets the same-origin Web Shell document request86 // the mic for voice dictation (the prompt won't even appear under an87 // empty `microphone=()` allowlist). Still blocks cross-origin iframes;88 // camera/geolocation/payment stay disabled (unused).89 'Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()')90 .set('Cache-Control', 'no-cache');91 // X-Frame-Options can't express an allowlist, so only send the hard DENY92 // when no extension is permitted to frame the shell; otherwise CSP93 // frame-ancestors (set above) governs framing.94 if (frameAncestors.length === 0) {95 res.set('X-Frame-Options', 'DENY');96 }97 // `dotfiles: 'allow'` is required because the resolved path may pass98 // through a dotfile directory (e.g. ~/.nvm/.../web-shell/index.html).99 // The `send` library defaults to 'ignore' which returns a 404 for any100 // path containing a segment starting with '.', breaking users who101 // installed qwen via nvm.102 res.sendFile(indexPath, { cacheControl: false, dotfiles: 'allow' }, (err) => {103 if (!err)104 return;105 // Only 5xx path in the serve app that would otherwise emit nothing —106 // log it like the sibling /demo handler so an operator can see why the107 // shell stopped loading (EACCES/ESTALE on a network mount, a perms108 // change, a partial deploy).109 writeStderrLine(`qwen serve: Web Shell index send failed: ${err instanceof Error ? err.message : String(err)}`);110 if (!res.headersSent) {111 res.status(500).type('text/plain').send('Failed to load Web Shell');112 }113 else {114 // Failed mid-stream (truncated/corrupt index.html): end the115 // half-written response instead of leaving the client on a 200 with a116 // partial body.117 res.end();118 }119 });120 };121}122/**123 * Mount the Web Shell static assets BEFORE `bearerAuth`. The shell carries no124 * secrets and a browser cannot attach an `Authorization` header to a125 * `<script src>` subresource or an address-bar navigation, so gating these126 * would just break the UI. The front-end's own API calls still carry the127 * bearer via `getDaemonAuthHeaders()`.128 *129 * - `GET /assets/*` — hashed, immutable build chunks (long-cache).130 * - `GET /` — the HTML shell, always (so `curl /` shows the UI too).131 *132 * Caller must have already verified `webShellDir` exists.133 */134export function mountWebShellAssets(app, webShellDir, frameAncestors = []) {135 const sendIndex = createSendIndex(webShellDir, frameAncestors);136 app.use('/assets', express.static(path.join(webShellDir, 'assets'), {137 index: false,138 immutable: true,139 maxAge: '1y',140 }));141 // A request still under /assets here is a missing chunk (e.g. a stale hashed142 // name after a redeploy) — return a clean 404 rather than letting it reach143 // the SPA fallback, which would answer a browser nav to /assets/<anything>144 // with a 200 index.html. (express.static's own `fallthrough: false` can't be145 // used: it forwards a 404 error to the catch-all error handler, which turns146 // it into a 500.)147 app.use('/assets', (req, res) => {148 // Quiet by default (a redeploy can briefly 404 many stale chunks); surface149 // it under serve debug mode so a white-screen shell has a diagnostic trail.150 if (isServeDebugMode()) {151 writeStderrLine(`qwen serve: Web Shell asset not found: ${req.originalUrl}`);152 }153 res.status(404).type('text/plain').send('Not found');154 });155 app.get('/', (_req, res) => sendIndex(res));156}157/**158 * Mount the SPA deep-link fallback (for navigations like `/session/<id>`).159 * Registered AFTER all API routes — just before the error handler — so real160 * routes, INCLUDING their `bearerAuth` 401s, always win and only genuine 404161 * misses fall through to the shell.162 *163 * This is what keeps a token-gated daemon honest: a navigation with an164 * attacker-controlled `Accept: text/html` to an authed route (e.g.165 * `/capabilities`, `/health` on a non-loopback bind) hits that route's real166 * response / 401, not this shell. Because real routes run first, no per-path167 * denylist is needed.168 *169 * Only GET/HEAD document navigations are claimed; API fetches send170 * `Accept: application/json`, fail `isDocumentNavigation`, and fall through to171 * the standard JSON 404.172 */173export function mountWebShellSpaFallback(app, webShellDir, frameAncestors = []) {174 const sendIndex = createSendIndex(webShellDir, frameAncestors);175 app.use((req, res, next) => {176 if (req.method !== 'GET' && req.method !== 'HEAD')177 return next();178 if (!isDocumentNavigation(req))179 return next();180 // Debug-only: lets an operator see deep-link navigations falling through to181 // the shell vs. hitting real routes (routing-misconfig / proxy diagnosis).182 if (isServeDebugMode()) {183 writeStderrLine(`qwen serve: Web Shell SPA fallback served for ${req.method} ${req.originalUrl}`);184 }185 sendIndex(res);186 });187}188//# sourceMappingURL=web-shell-static.js.map