fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.
This commit is contained in:
Maurice
2026-07-14 09:57:57 +02:00
parent 6c87bf2f7a
commit 4fad7f04a9
2 changed files with 34 additions and 2 deletions
@@ -61,8 +61,18 @@ export class PluginFrameController {
const ext = path.extname(resolved).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('X-Content-Type-Options', 'nosniff');
// The frame document runs at an OPAQUE origin (sandbox without
// allow-same-origin), so its own <script src>/<link> subresource loads are
// cross-origin — helmet's default CORP: same-origin makes the browser drop
// them (ERR_BLOCKED_BY_RESPONSE) and a multi-file client dies on boot.
// The sandbox + per-plugin CSP are the isolation boundary here, not CORP:
// these files are the plugin's own public client code.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId, req.get('host')));
res.sendFile(resolved);
// Explicit { root } + basename, not the absolute path: under the Nest
// ExpressAdapter, res.sendFile(absolutePath) resolves against the rewritten
// req.url and 404s spuriously (same trap as files-download.controller.ts).
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
}
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
+23 -1
View File
@@ -33,10 +33,15 @@ function fakeRes() {
headers: {} as Record<string, string>,
sent: undefined as unknown,
filePath: undefined as string | undefined,
fileRoot: undefined as string | undefined,
status(c: number) { res.statusCode = c; return res; },
setHeader(k: string, v: string) { res.headers[k] = v; },
send(b: unknown) { res.sent = b; return res; },
sendFile(p: string) { res.filePath = p; res.statusCode ||= 200; },
sendFile(p: string, opts?: { root?: string }) {
res.filePath = opts?.root ? path.join(opts.root, p) : p;
res.fileRoot = opts?.root;
res.statusCode ||= 200;
},
};
return res;
}
@@ -64,6 +69,23 @@ describe('PluginFrameController', () => {
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
});
// Under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves against the
// rewritten req.url and 404s spuriously — files must go out root-relative.
it('serves files with an explicit root, not an absolute path', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.fileRoot).toBe(path.join(codeRoot, 'widget', 'client'));
});
// The frame document runs at an opaque origin, so its own <script src> loads are
// cross-origin fetches; helmet's CORP: same-origin makes the browser drop them
// and a multi-file client dies on boot.
it('marks responses cross-origin loadable for the opaque frame', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
});
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
// in the manifest grants. activate() unions them into the child's egress guard; the frame
// must match, or a plugin with a UI can call the host from its server but not its iframe.