Apply consistent API response headers

Every /api response, including errors, partial responses, Host refusals and
unknown routes, now carries a sandboxing Content-Security-Policy and
X-Content-Type-Options: nosniff on the dev and preview servers. Vite's server
and preview headers only reach the documents and assets it serves itself, so
a small plugin sets them ahead of the Host check. A route's own policy is sent
as an additional policy rather than replacing it.
This commit is contained in:
Sameh Khamis committed 2026-10-21 15:07:00 -07:00
1 parent 275513bf2c
commit 72e1469968
2 files changed
+197 -6

No files matched your search

+53 -1
View File
@@ -47,6 +47,56 @@ export const BROWSER_HEADERS = Object.freeze({
'X-Content-Type-Options': 'nosniff',
});
/**
* Content-Security-Policy for every `/api` response. API routes return data,
* never a document, so a response opened directly in a tab gets no origin and
* can load nothing.
*/
export const API_CSP = "default-src 'none'; sandbox";
/** Response headers every `/api` response carries. */
export const API_HEADERS = Object.freeze({
'Content-Security-Policy': API_CSP,
'X-Content-Type-Options': 'nosniff',
});
/** Whether a request path is an API route (`/api` or `/api/...`). */
function isApiPath(url) {
const path = String(url || '/').split(/[?#]/, 1)[0];
return path === '/api' || path.startsWith('/api/');
}
/**
* Vite plugin that sets API_HEADERS on every API response before any other
* middleware runs, so success, error, partial, refused and unknown-route
* answers all carry them. Vite's `server.headers` and `preview.headers` only
* reach the documents and assets Vite serves itself. A route may still add
* headers; a route's own Content-Security-Policy is sent as an additional
* policy, which can only restrict further, never replace this one.
*/
export function apiResponseHeadersPlugin() {
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isApiPath(req.url)) return next();
for (const [name, value] of Object.entries(API_HEADERS))
res.setHeader(name, value);
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === 'content-security-policy' &&
String(value) !== API_CSP
? setHeader(name, `${API_CSP}, ${value}`)
: setHeader(name, value);
next();
});
};
return {
name: 'api-response-headers',
enforce: 'pre',
configureServer: install,
configurePreviewServer: install,
};
}
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
@@ -61,7 +111,9 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
// First, so no provider route answers a Host the server does not allow.
// First, so every API response, including a Host refusal, carries them.
apiResponseHeadersPlugin(),
// Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
+144 -5
View File
@@ -1,9 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer, request } from 'node:http';
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
import standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js';
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
const plugin = { name: 'fixture-provider' };
@@ -12,10 +14,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
// The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check');
// API response headers, then the Host check, run before every other
// plugin's middleware.
assert.equal(config.plugins[0].name, 'api-response-headers');
assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[3], plugin);
assert.equal(config.plugins[1].name, 'host-check');
assert.equal(config.plugins[1].enforce, 'pre');
assert.equal(config.plugins[4], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
@@ -75,7 +80,7 @@ test('build helper does not discover environment values or construct local provi
undefined,
);
assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name),
config.plugins.slice(4).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
} finally {
@@ -89,7 +94,7 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
const standaloneProviders = config.plugins
.slice(3, -5)
.slice(4, -5)
.map((plugin) => plugin.name);
assert.equal(
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
@@ -257,3 +262,137 @@ test('the CSP admits the event media embeds and the Maps JavaScript API, and no
// X posts load in X's own frames.
assert.ok(frames.includes('https://platform.twitter.com'));
});
/**
* Serve plugins' middleware over real HTTP the way Vite's connect stack mounts
* it: in plugin order, a route prefix stripped from `req.url`. A request no
* middleware answers gets the app document stand-in.
*/
async function servePlugins(t, plugins, hook, config) {
const stack = [];
const server = {
config,
middlewares: {
use(route, handler) {
if (typeof route === 'function')
stack.push({ route: '', handler: route });
else stack.push({ route, handler });
},
},
};
for (const plugin of plugins) plugin[hook]?.(server);
const http = createServer((req, res) => {
const original = req.url;
const path = original.split('?')[0];
let index = 0;
const next = () => {
const layer = stack[index++];
if (!layer) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('app');
return;
}
if (
layer.route &&
path !== layer.route &&
!path.startsWith(`${layer.route}/`)
)
return next();
req.url = original.slice(layer.route.length) || '/';
layer.handler(req, res, next);
};
next();
});
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => http.close(resolve)));
const { port } = http.address();
return (path, host = `localhost:${port}`) =>
new Promise((resolve, reject) => {
const req = request(
{ host: '127.0.0.1', port, path, headers: { host } },
(res) => {
res.resume();
res.on('end', () =>
resolve({ status: res.statusCode, headers: res.headers }),
);
},
);
req.on('error', reject);
req.end();
});
}
test('every API response carries the API policy and nosniff in dev and preview', async (t) => {
const policy = "default-src 'none'; sandbox";
const install = (server) => {
server.middlewares.use('/api/fixture', (req, res) => {
const route = req.url.split('?')[0];
if (route === '/ok') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
} else if (route === '/error') {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end('{"error":"fixture"}');
} else if (route === '/partial') {
res.writeHead(206, { 'Content-Range': 'bytes 0-0/2' });
res.end('x');
} else if (route === '/own-policy') {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.end('{}');
} else {
res.writeHead(200, { 'Content-Security-Policy': 'default-src *' });
res.end('{}');
}
});
};
const fixture = {
name: 'fixture-provider',
configureServer: install,
configurePreviewServer: install,
};
const built = createBrowserViteConfig({ plugins: [fixture] });
const plugins = [
...built.plugins.filter(
(plugin) =>
plugin === fixture ||
['api-response-headers', 'host-check'].includes(plugin.name),
),
apiNotFoundPlugin(),
];
const config = {
server: {
allowedHosts: built.server.allowedHosts,
host: built.server.host,
},
preview: { allowedHosts: built.server.allowedHosts },
};
for (const hook of ['configureServer', 'configurePreviewServer']) {
const get = await servePlugins(t, plugins, hook, config);
for (const [path, status, csp = policy, host] of [
['/api/fixture/ok', 200],
['/api/fixture/error', 500],
['/api/fixture/partial', 206],
['/api/nope', 404],
['/api', 404],
['/api/fixture/ok?x=1', 200],
['/api/fixture/ok', 403, policy, 'elsewhere.example'],
// A route's own policy is added to the API policy, never replaces it.
['/api/fixture/own-policy', 200, `${policy}, frame-ancestors 'none'`],
['/api/fixture/own-policy-head', 200, `${policy}, default-src *`],
]) {
const res = await get(path, host);
const label = `${hook} ${path} ${host || ''}`;
assert.equal(res.status, status, label);
assert.equal(res.headers['content-security-policy'], csp, label);
assert.equal(res.headers['x-content-type-options'], 'nosniff', label);
}
// The app document and other paths are left to the server's own headers.
for (const path of ['/', '/apiary', '/src/api/x.js'])
assert.equal(
(await get(path)).headers['content-security-policy'],
undefined,
`${hook} ${path}`,
);
}
});