mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Match API paths the way the router does
The router mounts /api without regard to letter case, accepts a '.' as well as '/' after the prefix, and routes absolute-form request targets, so the API response headers were missing on those spellings. Match the same way, on both the raw and the parsed path. The test now runs real dev and preview servers.
This commit is contained in:
1 parent
3e80afe713
commit
d80162bea2
3 files changed
+113
-106
No files matched your search
+16
-3
@@ -60,10 +60,23 @@ export const API_HEADERS = Object.freeze({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/** Whether a request path is an API route (`/api` or `/api/...`). */
|
||||
/**
|
||||
* Whether a request reaches an `/api` mount, by the router's own rule: the
|
||||
* prefix compared without case and followed by the end, `/` or `.`. Both the
|
||||
* raw path and the parsed one (which also covers an absolute-form target) are
|
||||
* checked, so no spelling the router mounts can miss the headers.
|
||||
*/
|
||||
function isApiPath(url) {
|
||||
const path = String(url || '/').split(/[?#]/, 1)[0];
|
||||
return path === '/api' || path.startsWith('/api/');
|
||||
const raw = String(url || '/');
|
||||
let parsed = '';
|
||||
try {
|
||||
parsed = new URL(raw, 'http://localhost').pathname;
|
||||
} catch {
|
||||
/* the raw path still decides */
|
||||
}
|
||||
return [raw.split(/[?#]/, 1)[0], parsed].some((path) =>
|
||||
/^\/api(?:$|[/.])/i.test(path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,11 +33,12 @@ existing recovery behavior.
|
||||
Every `/api` response on the dev and preview servers carries
|
||||
`Content-Security-Policy: default-src 'none'; sandbox` and
|
||||
`X-Content-Type-Options: nosniff`. `apiResponseHeadersPlugin` sets them ahead of
|
||||
the Host check, so errors, partial responses, Host refusals and unknown routes
|
||||
are covered too; a route's own policy is sent as an additional policy, never in
|
||||
place of it. The app document's policy is unchanged. The GBFS proxy relays only
|
||||
bodies that parse as JSON, re-serialized as `application/json; charset=utf-8`;
|
||||
anything else answers 502.
|
||||
the Host check and matches `/api` the way the router mounts it (any letter case, a
|
||||
`/` or `.` boundary, absolute-form targets), so errors, partial responses, Host
|
||||
refusals and unknown routes are covered too; a route's own policy is sent as an
|
||||
additional policy, never in place of it. The app document's policy is unchanged.
|
||||
The GBFS proxy relays only bodies that parse as JSON, re-serialized as
|
||||
`application/json; charset=utf-8`; anything else answers 502.
|
||||
|
||||
Catalog source contracts and their consuming layers are declared in
|
||||
`src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer, request } from 'node:http';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { build, createServer, preview } from 'vite';
|
||||
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';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
|
||||
const plugin = { name: 'fixture-provider' };
|
||||
@@ -263,70 +267,39 @@ test('the CSP admits the event media embeds and the Maps JavaScript API, and no
|
||||
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 });
|
||||
/** A raw GET, so the Host header and an absolute-form target are sent as given. */
|
||||
function rawGet(port, target, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: target,
|
||||
headers: { host: `localhost:${port}`, ...headers },
|
||||
},
|
||||
},
|
||||
};
|
||||
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();
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
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) => {
|
||||
test('every API response carries the API policy and nosniff on real dev and preview servers', async (t) => {
|
||||
const policy = "default-src 'none'; sandbox";
|
||||
const root = await makeFixtureRoot('gev-api-headers-');
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(
|
||||
path.join(root, 'index.html'),
|
||||
'<!doctype html><title>Fixture</title><p>x</p>',
|
||||
);
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/api/fixture', (req, res) => {
|
||||
const route = req.url.split('?')[0];
|
||||
const route = new URL(req.url, 'http://localhost').pathname;
|
||||
if (route === '/ok') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{}');
|
||||
@@ -352,47 +325,67 @@ test('every API response carries the API policy and nosniff in dev and preview',
|
||||
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 },
|
||||
const base = {
|
||||
root,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
};
|
||||
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);
|
||||
await build(base);
|
||||
for (const isPreview of [false, true]) {
|
||||
const server = await (isPreview ? preview : createServer)({
|
||||
...base,
|
||||
plugins: [
|
||||
...built.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin === fixture ||
|
||||
['api-response-headers', 'host-check'].includes(plugin.name),
|
||||
),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||
preview: { host: '127.0.0.1', port: 0 },
|
||||
});
|
||||
if (!isPreview) await server.listen();
|
||||
const { port } = server.httpServer.address();
|
||||
const mode = isPreview ? 'preview' : 'dev';
|
||||
try {
|
||||
for (const [target, status, csp = policy, headers] of [
|
||||
['/api/fixture/ok', 200],
|
||||
['/api/fixture/error', 500],
|
||||
['/api/fixture/partial', 206],
|
||||
['/api/nope', 404],
|
||||
['/api', 404],
|
||||
['/api/fixture/ok?x=1', 200],
|
||||
// Spellings the router mounts as the same routes.
|
||||
['/API/fixture/ok', 200],
|
||||
['/Api/fixture/error', 500],
|
||||
['/aPi/nope', 404],
|
||||
['/API', 404],
|
||||
['/api.x', 404],
|
||||
[`http://localhost:${port}/api/fixture/ok`, 200],
|
||||
[`http://localhost:${port}/API/nope`, 404],
|
||||
['/api/fixture/ok', 403, policy, { host: '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 rawGet(port, target, headers);
|
||||
const label = `${mode} ${target} ${JSON.stringify(headers || {})}`;
|
||||
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 keep the server's own headers.
|
||||
for (const target of ['/', '/apiary', '/src/api/x.js'])
|
||||
assert.notEqual(
|
||||
(await rawGet(port, target)).headers['content-security-policy'],
|
||||
policy,
|
||||
`${mode} ${target}`,
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user