diff --git a/packages/cli/src/serve/demo.ts b/packages/cli/src/serve/demo.ts new file mode 100644 index 00000000000..0868e82ad39 --- /dev/null +++ b/packages/cli/src/serve/demo.ts @@ -0,0 +1,640 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Inline HTML for the `/demo` debug page. Served as a single self-contained + * page with no external dependencies so it works without a build step or + * static-file serving. + */ +export function getDemoHtml(_port: number): string { + return /* html */ ` + + + + +Qwen Serve Demo + + + + +
+

Qwen Serve

+ Demo +
+ + Connecting... +
+
+ +
+ + +
+
+
Chat
+
Events
+
API Log
+
+ +
+
+
Create a session to start chatting
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+ + + +`; +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7ed73507ea4..d405961b313 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -3588,6 +3588,155 @@ describe('GET /session/:id/events (SSE)', () => { }); }); +describe('GET /demo', () => { + it('returns 200 with text/html content type on loopback', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/demo') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.text).toContain('Qwen Serve'); + expect(res.text).toContain(''); + }); + + it('is accessible without bearer token on loopback even when --token is set', async () => { + // Loopback: /demo is registered BEFORE bearerAuth so browsers can + // reach the page via address-bar navigation (no Authorization header). + const app = createServeApp({ ...baseOpts, token: 'secret' }, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/demo') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + }); + + it('requires bearer token on non-loopback (401 without token)', async () => { + // Non-loopback: /demo is registered AFTER bearerAuth to prevent + // unauthenticated access on public interfaces. + const app = createServeApp( + { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, + () => 4170, + { bridge: fakeBridge() }, + ); + const res = await request(app).get('/demo').set('Host', '0.0.0.0:4170'); + expect(res.status).toBe(401); + }); + + it('is accessible on non-loopback with valid bearer token', async () => { + const app = createServeApp( + { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, + () => 4170, + { bridge: fakeBridge() }, + ); + const res = await request(app) + .get('/demo') + .set('Host', '0.0.0.0:4170') + .set('Authorization', 'Bearer secret'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + }); + + it('is guarded by CORS (rejects cross-origin requests)', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/demo') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'https://evil.example.com'); + expect(res.status).toBe(403); + }); + + it('sets anti-clickjacking headers (X-Frame-Options + CSP)', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/demo') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.headers['x-frame-options']).toBe('DENY'); + expect(res.headers['content-security-policy']).toContain( + "frame-ancestors 'none'", + ); + }); +}); + +describe('same-origin Origin-stripping middleware', () => { + it('strips loopback Origin header matching daemon port', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + // A request with matching same-origin should pass CORS check + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://127.0.0.1:4170'); + // Should NOT be rejected by denyBrowserOriginCors (status != 403) + expect(res.status).not.toBe(403); + }); + + it('does not strip non-loopback Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://evil.com:4170'); + expect(res.status).toBe(403); + }); + + it('does not strip Origin with wrong port', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://127.0.0.1:9999'); + expect(res.status).toBe(403); + }); + + it('strips host.docker.internal Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://host.docker.internal:4170'); + expect(res.status).not.toBe(403); + }); + + it('strips localhost Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://localhost:4170'); + expect(res.status).not.toBe(403); + }); + + it('strips [::1] Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://[::1]:4170'); + expect(res.status).not.toBe(403); + }); +}); + describe('runQwenServe SIGINT handler', () => { it('does not register signal handlers until the listener is up', () => { // Sanity: we register `once` so we don't leak across test runs. diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 6323662c83a..a1a3d18ec98 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -39,6 +39,7 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from './types.js'; +import { getDemoHtml } from './demo.js'; import { mountWorkspaceMemoryRoutes } from './workspaceMemory.js'; import { mountWorkspaceAgentsRoutes } from './workspaceAgents.js'; import { @@ -213,6 +214,35 @@ export function createServeApp( boundWorkspace, }); + // Allow same-origin requests from the demo page. Browsers send an + // `Origin` header on same-origin POST/fetch calls; `denyBrowserOriginCors` + // below would reject them. This middleware strips `Origin` when it + // matches the daemon's own address so the demo page's API calls pass + // through. Only loopback origins are matched — non-loopback deployments + // require the operator to front the daemon with a reverse proxy for + // browser access anyway (per the threat-model docs). + let cachedStripPort = -1; + let cachedSelfOrigins: Set = new Set(); + app.use((req: import('express').Request, _res, next) => { + const origin = req.headers.origin; + if (origin) { + const port = getPort(); + if (port !== cachedStripPort) { + cachedStripPort = port; + cachedSelfOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + `http://host.docker.internal:${port}`, + ]); + } + if (cachedSelfOrigins.has(origin)) { + delete req.headers.origin; + } + } + next(); + }); + // Strict-default factory: `trusted: false` so an upstream refactor // that forgets to inject `deps.fsFactory` never silently allows // writes against an untrusted workspace. Read-shaped intents still @@ -247,6 +277,38 @@ export function createServeApp( app.use(denyBrowserOriginCors); app.use(hostAllowlist(opts.hostname, getPort)); + // --- Demo page: mirrors the `/health` loopback-gating pattern. + // On loopback binds, registered BEFORE bearerAuth so browsers can + // reach the page via address-bar navigation (which cannot attach + // Authorization headers). On non-loopback binds, registered AFTER + // bearerAuth — an unauthenticated `/demo` on a public interface + // would leak the full API surface (route enumeration + interactive + // console), far more than `/health`'s `{"status":"ok"}`. + // X-Frame-Options: DENY + CSP frame-ancestors 'none' prevent + // clickjacking — a malicious site embedding the demo in an iframe + // could trick a user into performing daemon actions via transparent + // overlay (the iframe's same-origin fetches bypass CORS). + const demoHandler = ( + _req: import('express').Request, + res: import('express').Response, + ) => { + try { + res + .type('html') + .set('X-Frame-Options', 'DENY') + .set( + 'Content-Security-Policy', + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", + ) + .send(getDemoHtml(getPort())); + } catch (err) { + writeStderrLine( + `qwen serve: /demo render failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ error: 'Failed to render demo page' }); + } + }; + // `/health` is exempted from `bearerAuth` ONLY on loopback binds — // the canonical liveness-probe case (k8s/Compose probes don't // carry the daemon's bearer; round-tripping a 401 just to know @@ -309,17 +371,21 @@ export function createServeApp( const exposeHealthPreAuth = loopback && !opts.requireAuth; if (exposeHealthPreAuth) { app.get('/health', healthHandler); + app.get('/demo', demoHandler); } app.use(bearerAuth(opts.token)); + app.use(express.json({ limit: '10mb' })); if (!exposeHealthPreAuth) { // Non-loopback OR loopback with `--require-auth`: register - // `/health` AFTER `bearerAuth` so probes must carry the token. - // Otherwise unauthenticated callers can ping any reachable - // address:port to confirm a daemon exists. + // `/health` and `/demo` AFTER `bearerAuth` so probes must carry + // the token. Otherwise unauthenticated callers can ping any + // reachable address:port to confirm a daemon exists (and `/demo` + // leaks the full API surface). app.get('/health', healthHandler); + app.get('/demo', demoHandler); } // Issue #4175 PR 15. Mutation-route gate factory. Today's existing