Skip to content

Commit 5ca1c6e

Browse files
feat(webui): make the proxied Mini App document framable and carry the bridge
Second slice of #255. The page is framed by this client on a different origin, so a page must not be able to refuse being framed. The proxy drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` directive from a page's own CSP, leaving its other directives alone — the Mini App's origin is separate from the client's, so the rest of its policy governs a document that cannot reach the client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own. The document is the one response the proxy rewrites. The page bridge has to be in the document before the page's own scripts run, so the proxy injects a script tag for it before `</head>`, after the opening `<body>` tag when a page has no head, or ahead of the document when it has neither; the body it sends is not the body it received, so it restates `Content-Length` and drops the upstream's `Transfer-Encoding` with it. The bridge script is served by the proxy itself at its own path and never forwarded upstream, which keeps the seam in one place: this change owns which document, where the tag goes and what headers survive, while the bridge body arrives with #260. ADR 0020's open question about framing is settled by these rules and removed from the open list; the bridge's method-level error mapping is the only one left. Proven: the bridge is injected before `</head>`, before `<body>` on a headless document, `X-Frame-Options` is gone, `frame-ancestors` is gone while `default-src` and `script-src` survive, the bridge path is served locally without reaching the upstream, and paths and bodies of ordinary responses are untouched. Verified: pnpm typecheck:webui (server, client and test); the focused suite (8 cases); pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on the pre-existing WorkspacePanels.tsx violation described in the previous commit.
1 parent b3b0981 commit 5ca1c6e

3 files changed

Lines changed: 147 additions & 7 deletions

File tree

‎packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,13 +435,23 @@ above. The plugin catalogue's apps area rides with that change rather than after
435435
that shows what is installed and what is running, so without it the view has nothing to be opened from. The agent capability follows the view: it is the only part that can be reviewed against a
436436
working page, since a plugin's agent calls are only observable from a page this client can display.
437437

438+
### The proxy makes the document framable
439+
440+
The page is framed by this client, on a different origin, so a page must not be able to refuse. The proxy
441+
therefore drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors`
442+
directive from a page's own CSP while leaving its other directives alone — the Mini App's origin is a
443+
separate origin from the client's, so the rest of its policy governs a document that cannot reach the
444+
client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own.
445+
446+
The document is the one response the proxy rewrites. The page bridge has to be in the document before the
447+
page's own scripts run, so the proxy injects a script tag for it before `</head>`, or after the opening
448+
`<body>` tag when a page has no head, or ahead of the document when it has neither, and restates the
449+
`Content-Length` because the body it sends is not the body it received. Everything else — paths, methods,
450+
status codes, the page's own headers — passes through unchanged.
451+
438452
## Open implementation questions
439453

440-
1. **Framing the page through the proxy.** One listener per Mini App is decided above; what remains is
441-
which response headers the proxy must add or drop for the document to be framable at all, and what
442-
it does with a Mini App that sets framing or CSP headers of its own. The desktop client offers no
443-
precedent: it never frames the page and writes no response headers of its own.
444-
2. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the
454+
1. **The bridge's method-level error mapping.** Which failure code each bridge call answers when the
445455
host cannot serve it — an unsupported capability, a full queue, a runtime that stopped between two
446456
calls — and how a page's request identifier survives the `postMessage` boundary.
447457

‎packages/webui/src/server/miniapp/proxy.ts‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,28 @@ export function deriveMiniAppProxyPort(pluginId: string): number {
3737
return PROXY_PORT_MIN + (digest.readUInt32BE(0) % PROXY_PORT_RANGE);
3838
}
3939

40+
/**
41+
* The page bridge the proxy injects into the Mini App's document.
42+
*
43+
* The proxy owns the seam — which document, where the tag goes, what headers survive — while the script
44+
* itself belongs to the bridge, which knows what `window.mcode.miniapp` exposes.
45+
*/
46+
export interface MiniAppPageBridge {
47+
/** Absolute path on the proxy origin that serves the script, e.g. `/__miniapp/bridge.js`. */
48+
readonly path: string;
49+
readonly script: string;
50+
}
51+
4052
export interface StartMiniAppProxyInput {
4153
readonly target: MiniAppProxyTarget;
4254
/** Defaults to the derived port; a test or a retry may pass the next candidate. */
4355
readonly port?: number;
56+
readonly bridge?: MiniAppPageBridge;
4457
}
4558

59+
const FRAME_BLOCKING_HEADERS = ['x-frame-options'] as const;
60+
const CSP_HEADER = 'content-security-policy';
61+
4662
/**
4763
* One loopback listener per running Mini App.
4864
*
@@ -62,6 +78,12 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise<
6278
const sockets = new Set<{ destroy(): void }>();
6379

6480
const server = http.createServer((request, response) => {
81+
if (input.bridge && request.url === input.bridge.path) {
82+
response
83+
.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' })
84+
.end(input.bridge.script);
85+
return;
86+
}
6587
const forwarded = http.request(
6688
{
6789
host: upstream.hostname,
@@ -75,8 +97,28 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise<
7597
agent: false,
7698
},
7799
(upstreamResponse) => {
78-
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
79-
upstreamResponse.pipe(response);
100+
const headers = frameableHeaders(upstreamResponse.headers);
101+
if (!isHtml(upstreamResponse.headers) || !input.bridge) {
102+
response.writeHead(upstreamResponse.statusCode ?? 502, headers);
103+
upstreamResponse.pipe(response);
104+
return;
105+
}
106+
// The document is the one response this proxy rewrites: the page bridge has to be in it before
107+
// the page's own scripts run, and a page cannot be allowed to decide it will not be framed.
108+
const chunks: Buffer[] = [];
109+
upstreamResponse.on('data', (chunk: Buffer) => chunks.push(chunk));
110+
upstreamResponse.on('end', () => {
111+
const body = Buffer.from(
112+
injectBridge(Buffer.concat(chunks).toString('utf8'), input.bridge as MiniAppPageBridge),
113+
'utf8',
114+
);
115+
// The body length changed, so the upstream framing no longer applies.
116+
delete headers['transfer-encoding'];
117+
headers['content-length'] = String(body.byteLength);
118+
response.writeHead(upstreamResponse.statusCode ?? 502, headers);
119+
response.end(body);
120+
});
121+
upstreamResponse.on('error', () => response.destroy());
80122
},
81123
);
82124
forwarded.on('error', () => {
@@ -147,3 +189,43 @@ export async function startMiniAppProxy(input: StartMiniAppProxyInput): Promise<
147189
},
148190
};
149191
}
192+
193+
function isHtml(headers: http.IncomingHttpHeaders): boolean {
194+
const contentType = headers['content-type'];
195+
return typeof contentType === 'string' && contentType.toLowerCase().includes('text/html');
196+
}
197+
198+
/**
199+
* The page is framed by the WebUI client, on a different origin, so a page that asks not to be framed
200+
* must not be able to refuse: `X-Frame-Options` is dropped and a `frame-ancestors` directive is removed
201+
* from the page's own CSP. Everything else the page declares is left alone — the Mini App's origin is a
202+
* separate origin from the client's, so its CSP governs a document that cannot reach the client anyway.
203+
*/
204+
function frameableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
205+
const result: http.OutgoingHttpHeaders = {};
206+
for (const [name, value] of Object.entries(headers)) {
207+
if (FRAME_BLOCKING_HEADERS.includes(name.toLowerCase() as 'x-frame-options')) continue;
208+
if (name.toLowerCase() === CSP_HEADER && typeof value === 'string') {
209+
const directives = value
210+
.split(';')
211+
.map((directive) => directive.trim())
212+
.filter((directive) => directive.length > 0 && !directive.toLowerCase().startsWith('frame-ancestors'));
213+
result[name] = directives.join('; ');
214+
continue;
215+
}
216+
result[name] = value;
217+
}
218+
return result;
219+
}
220+
221+
function injectBridge(document: string, bridge: MiniAppPageBridge): string {
222+
const tag = `<script src="${bridge.path}"></script>`;
223+
const headEnd = document.search(/<\/head\s*>/iu);
224+
if (headEnd >= 0) return `${document.slice(0, headEnd)}${tag}${document.slice(headEnd)}`;
225+
const bodyStart = document.search(/<body[^>]*>/iu);
226+
if (bodyStart >= 0) {
227+
const insertAt = document.indexOf('>', bodyStart) + 1;
228+
return `${document.slice(0, insertAt)}${tag}${document.slice(insertAt)}`;
229+
}
230+
return `${tag}${document}`;
231+
}

‎packages/webui/test/unit/miniapp-proxy.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ let proxy: MiniAppProxy;
2828
beforeAll(async () => {
2929
upstream = http.createServer((request, response) => {
3030
seen.push({ method: request.method, url: request.url, headers: { ...request.headers } });
31+
if (request.url === '/page') {
32+
response
33+
.writeHead(200, {
34+
'content-type': 'text/html; charset=utf-8',
35+
'x-frame-options': 'DENY',
36+
'content-security-policy': "default-src 'self'; frame-ancestors 'none'; script-src 'self'",
37+
})
38+
.end('<!doctype html><html><head><title>app</title></head><body>page</body></html>');
39+
return;
40+
}
41+
if (request.url === '/bare') {
42+
response.writeHead(200, { 'content-type': 'text/html' }).end('<p>no head here</p>');
43+
return;
44+
}
3145
if (request.url === '/echo') {
3246
response.writeHead(200, { 'content-type': 'text/plain' }).end('from-upstream');
3347
return;
@@ -52,6 +66,7 @@ beforeAll(async () => {
5266
credentialHeader: CREDENTIAL_HEADER,
5367
},
5468
port: 0,
69+
bridge: { path: '/__miniapp/bridge.js', script: 'globalThis.__bridge = true;' },
5570
});
5671
});
5772

@@ -142,3 +157,36 @@ describe('Mini App proxy', () => {
142157
expect(deriveMiniAppProxyPort('another.miniapp')).not.toBe(first);
143158
});
144159
});
160+
161+
describe('Mini App proxy document rewrite', () => {
162+
it('injects the bridge before the document head ends', async () => {
163+
const result = await get(proxy.port, '/page');
164+
165+
expect(result.status).toBe(200);
166+
expect(result.body).toContain('<script src="/__miniapp/bridge.js"></script></head>');
167+
expect(result.body).toContain('<title>app</title>');
168+
});
169+
170+
it('makes the document framable: drops X-Frame-Options and the frame-ancestors directive', async () => {
171+
const result = await get(proxy.port, '/page');
172+
173+
expect(result.headers).not.toHaveProperty('x-frame-options');
174+
expect(result.headers['content-security-policy']).toBe("default-src 'self'; script-src 'self'");
175+
});
176+
177+
it('injects before the body when the document has no head', async () => {
178+
const result = await get(proxy.port, '/bare');
179+
180+
expect(result.body).toBe('<script src="/__miniapp/bridge.js"></script><p>no head here</p>');
181+
});
182+
183+
it('serves the bridge itself and never forwards that path upstream', async () => {
184+
const before = seen.length;
185+
const result = await get(proxy.port, '/__miniapp/bridge.js');
186+
187+
expect(result.status).toBe(200);
188+
expect(result.body).toBe('globalThis.__bridge = true;');
189+
expect(result.headers['content-type']).toContain('text/javascript');
190+
expect(seen.length).toBe(before);
191+
});
192+
});

0 commit comments

Comments
 (0)