diff --git a/packages/cli/src/serve/demo.ts b/packages/cli/src/serve/demo.ts new file mode 100644 index 0000000000..ea92329972 --- /dev/null +++ b/packages/cli/src/serve/demo.ts @@ -0,0 +1,589 @@ +/** + * @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 c9ec984b3d..7fa6b85fa0 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -1425,6 +1425,95 @@ describe('GET /session/:id/events (SSE)', () => { }); }); +describe('GET /demo', () => { + it('returns 200 with text/html content type', 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 even when --token is set (before bearerAuth)', async () => { + // /demo is registered BEFORE bearerAuth so browsers can reach the + // page via address-bar navigation (which cannot attach Authorization + // headers). The in-page token input authenticates subsequent API calls. + 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(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + }); + + it('is still guarded by CORS and Host allowlist', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + // Cross-origin request should be rejected by denyBrowserOriginCors + 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); + }); +}); + +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); + }); +}); + 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 456b2ffe8a..c842b2a87d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -24,6 +24,7 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from './types.js'; +import { getDemoHtml } from './demo.js'; export interface ServeAppDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ @@ -64,6 +65,30 @@ export function createServeApp( const bridge = deps.bridge ?? createHttpAcpBridge({ maxSessions: opts.maxSessions }); + // 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). + app.use((req: import('express').Request, _res, next) => { + const origin = req.headers.origin; + if (origin) { + const port = getPort(); + const selfOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + `http://host.docker.internal:${port}`, + ]); + if (selfOrigins.has(origin)) { + delete req.headers.origin; + } + } + next(); + }); + // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST // gets a full 10MB `JSON.parse` before the 401 fires — a trivially @@ -71,6 +96,24 @@ export function createServeApp( app.use(denyBrowserOriginCors); app.use(hostAllowlist(opts.hostname, getPort)); + // --- Demo page: registered AFTER CORS and Host allowlist but BEFORE + // bearerAuth. Browsers cannot attach an Authorization header on + // address-bar navigation, so a bearer-protected /demo would be + // unreachable — the in-page token input field would never load. + // Serving only the static HTML shell here is safe: the page itself + // contains no secrets, and all daemon API/SSE routes remain behind + // bearerAuth so the token field authenticates subsequent calls. + app.get('/demo', (_req, res) => { + try { + res.type('html').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 @@ -127,6 +170,7 @@ export function createServeApp( } app.use(bearerAuth(opts.token)); + app.use(express.json({ limit: '10mb' })); if (!loopback) {