mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
fix(news): gate /api/news same-site, answer 400 on a bad request line, relay key states
A malformed request line crashed the server, any web page could spend the news keys, and the forwarding proxy turned the news server's 503 key and configuration states into a generic 502. Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
1 parent
3342d7a07a
commit
49ac289d00
3 files changed
+119
-10
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { newsQueryAllowed } from '../../src/layers/news/source.js';
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
import { sameSiteGated } from './common/same-site.js';
|
||||
import { createKeyedNews } from './news/keyed.js';
|
||||
|
||||
// News (/api/news/*). With GEV_NEWS_ORIGIN set (a news server such as
|
||||
@@ -62,6 +63,25 @@ function configuredOrigin(env, log) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A 503's known key or configuration state, rebuilt from its fields; else null. */
|
||||
async function upstreamState(upstream) {
|
||||
let body = null;
|
||||
try {
|
||||
body = JSON.parse(await readResponseTextCapped(upstream, 16 * 1024));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (body?.error === 'news_not_configured')
|
||||
return { error: body.error, unavailable: true, retryable: false };
|
||||
if (
|
||||
['no_key', 'key_rejected', 'key_quota'].includes(body?.error) &&
|
||||
typeof body.key === 'string' &&
|
||||
/^[a-z][a-z0-9-]{0,31}$/.test(body.key)
|
||||
)
|
||||
return { error: body.error, key: body.key };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin, validated GET proxy for the news layer's three read routes, or
|
||||
* their keyed answers without a news server. A host that serves news only
|
||||
@@ -91,11 +111,17 @@ export function newsProxy({
|
||||
async function handler(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return json(res, 405, { error: 'method_not_allowed' });
|
||||
const local = new URL(req.url || '/', 'http://local');
|
||||
const target = new URL(
|
||||
`/api/news${local.pathname}${local.search}`,
|
||||
'http://local',
|
||||
);
|
||||
let local, target;
|
||||
try {
|
||||
// `//[` and the like are no URL: a 400, not an unhandled rejection.
|
||||
local = new URL(req.url || '/', 'http://local');
|
||||
target = new URL(
|
||||
`/api/news${local.pathname}${local.search}`,
|
||||
'http://local',
|
||||
);
|
||||
} catch {
|
||||
return json(res, 400, { error: 'invalid_news_request' });
|
||||
}
|
||||
if (!newsQueryAllowed(target))
|
||||
return json(res, 400, { error: 'invalid_news_request' });
|
||||
const base = origin();
|
||||
@@ -126,6 +152,12 @@ export function newsProxy({
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
},
|
||||
);
|
||||
if (upstream.status === 503) {
|
||||
// The news server's own key and configuration states reach the
|
||||
// panel as they are, so it can say which key is missing.
|
||||
const state = await upstreamState(upstream);
|
||||
if (state) return json(res, 503, state);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
await upstream.body?.cancel().catch(() => {});
|
||||
return json(res, 502, {
|
||||
@@ -144,13 +176,16 @@ export function newsProxy({
|
||||
}
|
||||
}
|
||||
|
||||
// The routes spend keys and upstream reads: no other site's page, and no
|
||||
// proxied request, may drive them (SECURITY.md, cross-site gate).
|
||||
const gated = sameSiteGated(handler);
|
||||
return {
|
||||
name: 'news',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/news', handler);
|
||||
middlewares.use('/api/news', gated);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/news', handler);
|
||||
middlewares.use('/api/news', gated);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ function mount(options) {
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url, method = 'GET') => {
|
||||
return async (url, method = 'GET', headers = {}) => {
|
||||
const res = {
|
||||
destroyed: false,
|
||||
status: 0,
|
||||
@@ -24,11 +24,15 @@ function mount(options) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
end(body) {
|
||||
if (this.statusCode) this.status = this.statusCode;
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url, method }, res);
|
||||
await handler({ url, method, headers }, res);
|
||||
return { ...res, json: res.body ? JSON.parse(res.body) : null };
|
||||
};
|
||||
}
|
||||
@@ -218,3 +222,73 @@ test('a host that serves news only from its own server turns the built-in backen
|
||||
}
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
test('a request line that is no URL is a 400, not a crash', async () => {
|
||||
const call = mount({
|
||||
origin: () => null,
|
||||
keyed: { answer: async () => assert.fail('nothing is answered') },
|
||||
});
|
||||
// `GET /api/news//[`: Connect strips the mount prefix and leaves `//[`.
|
||||
const bad = await call('//[');
|
||||
assert.equal(bad.status, 400);
|
||||
assert.deepEqual(bad.json, { error: 'invalid_news_request' });
|
||||
});
|
||||
|
||||
test('cross-site and proxied requests are refused before anything is read', async () => {
|
||||
let answered = 0;
|
||||
const call = mount({
|
||||
origin: () => null,
|
||||
keyed: {
|
||||
answer: async () => {
|
||||
answered++;
|
||||
return { status: 200, body: { reports: [], nextBefore: null } };
|
||||
},
|
||||
},
|
||||
});
|
||||
const host = 'localhost:5173';
|
||||
for (const headers of [
|
||||
{ host, origin: 'https://evil.example' },
|
||||
{ host, origin: 'null' },
|
||||
{ host, 'sec-fetch-site': 'cross-site' },
|
||||
{ host, 'x-forwarded-for': '203.0.113.9' },
|
||||
]) {
|
||||
const refused = await call('/feed?hotspot=all', 'GET', headers);
|
||||
assert.equal(refused.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(answered, 0);
|
||||
for (const headers of [
|
||||
{ host },
|
||||
{ host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin' },
|
||||
]) {
|
||||
const passed = await call('/feed?hotspot=all', 'GET', headers);
|
||||
assert.equal(passed.status, 200, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(answered, 2);
|
||||
});
|
||||
|
||||
test("a news server's key and configuration states are relayed as they are", async () => {
|
||||
for (const body of [
|
||||
{ error: 'no_key', key: 'worldnewsapi' },
|
||||
{ error: 'key_rejected', key: 'worldnewsapi' },
|
||||
{ error: 'key_quota', key: 'worldnewsapi' },
|
||||
{ error: 'news_not_configured', unavailable: true, retryable: false },
|
||||
]) {
|
||||
const call = mount({
|
||||
origin: () => 'https://news.example',
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify(body), { status: 503 }),
|
||||
});
|
||||
const relayed = await call('/feed?hotspot=all&at=31.95,35.93');
|
||||
assert.equal(relayed.status, 503, body.error);
|
||||
assert.deepEqual(relayed.json, body);
|
||||
}
|
||||
// Any other 503 stays a bounded upstream error.
|
||||
const other = mount({
|
||||
origin: () => 'https://news.example',
|
||||
fetchImpl: async () =>
|
||||
new Response('{"error":"maintenance","detail":"x"}', { status: 503 }),
|
||||
});
|
||||
const down = await other('/hotspots');
|
||||
assert.equal(down.status, 502);
|
||||
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 503 });
|
||||
});
|
||||
Reference in new issue
Block a user