mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(taps): shared address contract for receiver taps (#382)
* feat(taps): shared address contract for receiver taps Receiver taps (#57 RTL-SDR, #56 Rayhunter, #7 TAK, #6 Meshtastic) differ from every other provider in this tree in one way that matters: the upstream address comes from the user at runtime rather than from a fixed constant, so each tap route is an SSRF surface unless the address is constrained. Today each tap would answer that question for itself, and they would disagree. parseTapAddress() is the single place the rule lives: A tap may address loopback or an RFC1918 private network, plus the `localhost` and `*.local` (mDNS) names. Nothing else. That is what a tap is for — reading your own device on your own network. A tap pointed at a public address is either a mistake or the proxy being used as a confused deputy, and both get the same answer. Link-local (169.254/16) is excluded wholesale, which covers the cloud metadata address by construction rather than by blocklist. Deliberately NOT reusing isNonGlobalIpv4() from radio/stations.js: it reports malformed input like 999.1.1.1 as non-global, which is the safe direction for the radio proxy's "is this public enough to fetch?" test but inverts into *allowing* garbage under a tap's "is this local enough to fetch?" test. A positive allowlist cannot invert that way, and the tests pin that case. Two documented non-goals so they don't read as oversights: no general hostname resolution (a name other than localhost/*.local is rejected rather than resolved-and-pinned, avoiding DNS-rebinding defence for no gain), and no IPv6 (zone-index handling is platform-specific and no tap has needed it). Scoped to the address rule alone. The recorded-fixture harness lands with the first tap, designed against a real recorded response rather than guessed at with no consumer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01KGKBNNjPh4iQe8sviGZNPb * style: adopt the tap address contract into the format scope Mechanical. Adds server/providers/common/tap-address.js and its test to scripts/format-scope.json and applies prettier, matching the six sibling modules in server/providers/common/ that are already adopted. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01KGKBNNjPh4iQe8sviGZNPb * docs(taps): state accurately which taps this contract governs The header claimed #57, #56, #7 and #6 as consumers. Only #56 (Rayhunter) actually is today: it takes ?base=host:port from the browser, which is the shape that makes a route an SSRF surface. #57 would qualify if built server-side. The other three are different shapes, and forcing them through a private-only check would be wrong rather than merely unnecessary: - Meshtastic (#6) reads a fixed public broker, mqtt.meshtastic.org. - WiGLE (#11) reads a fixed public API behind a token. - TAK (#7) takes its host from operator-set env config rather than from the browser, and a TAK server is frequently a legitimate remote host. No behaviour change; comment only. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01KGKBNNjPh4iQe8sviGZNPb * refactor(taps): move the tap address contract to src/data so both sides share it The browser needs the same answer the server does — the server as a trust boundary, the client to give immediate feedback on a typed address without a round trip. Keeping the rule under server/providers/ would have meant the client re-deriving it, which is how the Rayhunter branch already carried a hand-synced copy of the regex with a comment admitting it. #274 is open about eight independent copies of haversine, one of which had silently swapped its arguments. Shipping a new duplicated validator across a trust boundary in the same tree would be hard to defend. server/providers/gbfs.js already imports src/data/gbfsSource.js, so shared validation living in src/data with the server importing it is the established direction here rather than a new one. Pure move plus the path/docblock updates. No behaviour change; the 12 contract tests are untouched and still pass. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01KGKBNNjPh4iQe8sviGZNPb --------- Co-authored-by: Claude Opus 5 (1M context) <[email protected]> Co-authored-by: Bilawal Sidhu <[email protected]>
This commit is contained in:
3 files changed
+298
No files matched your search
@@ -99,6 +99,8 @@
|
||||
"src/tooling/previewServing.test.mjs",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
"src/data/tapAddress.test.mjs",
|
||||
"src/data/tapAddress.js",
|
||||
"server/providers/local.js",
|
||||
"server/providers/military-installations.js",
|
||||
"server/providers/military-installations/cache.js",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file Shared address contract for receiver taps.
|
||||
*
|
||||
* A "tap" is a layer fed by hardware the user owns and runs themselves. What
|
||||
* this module governs is narrower than "tap" in general: it is for taps whose
|
||||
* upstream address arrives **from the browser at request time**, which is what
|
||||
* makes the route an SSRF surface. Today that is the Rayhunter device (#56),
|
||||
* which is addressed as `?base=host:port`, and it would cover an RTL-SDR /
|
||||
* dump1090 receiver (#57) if that one is built server-side.
|
||||
*
|
||||
* It deliberately does NOT cover the other layers in the receiver-taps lane,
|
||||
* because they are a different shape and forcing them through this check would
|
||||
* be wrong:
|
||||
*
|
||||
* - Meshtastic (#6) reads a fixed public broker (`mqtt.meshtastic.org`).
|
||||
* - WiGLE (#11) reads a fixed public API behind a token.
|
||||
* - TAK (#7) takes its host from operator-set env config, not from the
|
||||
* browser, and a TAK server is frequently a legitimate REMOTE host — a
|
||||
* private-only rule would break it.
|
||||
*
|
||||
* This module is the single place that constraint lives, so taps cannot each
|
||||
* re-derive it and disagree. The rule is deliberately narrow:
|
||||
*
|
||||
* A tap may address the loopback interface or an RFC1918 private network,
|
||||
* plus the `localhost` and `*.local` (mDNS) names. Nothing else.
|
||||
*
|
||||
* That is the whole point of a tap — it reads *your* device on *your* network.
|
||||
* A tap pointed at a public address is either a mistake or someone using the
|
||||
* proxy as a confused deputy, and both deserve the same answer.
|
||||
*
|
||||
* Two deliberate non-goals, so the next person does not think they were
|
||||
* oversights:
|
||||
*
|
||||
* 1. No general hostname resolution. A name other than `localhost`/`*.local`
|
||||
* is rejected outright rather than resolved-and-pinned. Local devices are
|
||||
* reachable by IP or mDNS, so supporting arbitrary names would buy nothing
|
||||
* and would drag in DNS-rebinding defence (resolve, pin, re-check on every
|
||||
* redirect) that `server/providers/radio/transport.js` already shows is not
|
||||
* small. If a tap ever genuinely needs it, pin it there rather than
|
||||
* loosening the check here.
|
||||
* 2. No IPv6. Link-local IPv6 carries a zone index (`%eth0`) whose handling is
|
||||
* platform-specific, and no tap has needed it. Rejected explicitly rather
|
||||
* than half-supported.
|
||||
*
|
||||
* Lives in `src/data/` rather than under `server/providers/` because both
|
||||
* sides need the same answer: the server as a trust boundary, the browser to
|
||||
* give immediate feedback on a typed address without a round trip. Sharing one
|
||||
* module means they cannot drift — the same reason #274 exists about eight
|
||||
* copies of haversine. `server/providers/gbfs.js` importing
|
||||
* `src/data/gbfsSource.js` is the established direction for this.
|
||||
*
|
||||
* Note on reuse: `isNonGlobalIpv4()` in `server/providers/radio/stations.js` answers a
|
||||
* neighbouring question and was deliberately NOT reused. It reports malformed
|
||||
* input such as `999.1.1.1` as non-global, which is the safe direction for the
|
||||
* radio proxy's "is this public enough to fetch?" test but inverts into
|
||||
* *allowing* garbage under a tap's "is this local enough to fetch?" test. The
|
||||
* positive allowlist below cannot invert that way.
|
||||
*
|
||||
* @module data/tapAddress
|
||||
*/
|
||||
|
||||
/** Link-local (169.254/16) is excluded wholesale, which also covers the cloud
|
||||
* metadata address at 169.254.169.254 by construction rather than by blocklist. */
|
||||
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
/** Single mDNS label or dotted name ending in `.local`, e.g. `rayhunter.local`. */
|
||||
const MDNS_RE = /^(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})*\.local$/;
|
||||
const MAX_PORT = 65535;
|
||||
|
||||
/**
|
||||
* Whether a dotted-quad string is a loopback or RFC1918 private address.
|
||||
*
|
||||
* Written as a positive allowlist: every octet must parse and be in range
|
||||
* before any range test runs, so malformed input can never fall through to
|
||||
* "allowed".
|
||||
*
|
||||
* @param {string} host
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalIpv4(host) {
|
||||
const match = IPV4_RE.exec(host);
|
||||
if (!match) return false;
|
||||
const [a, b] = [Number(match[1]), Number(match[2])];
|
||||
const octets = match.slice(1).map(Number);
|
||||
if (
|
||||
octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
a === 127 || // loopback
|
||||
a === 10 || // 10/8
|
||||
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
|
||||
(a === 192 && b === 168) // 192.168/16
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a user-supplied `host:port` tap address.
|
||||
*
|
||||
* @param {unknown} raw e.g. `"192.168.1.1:8080"`, `"localhost:8080"`.
|
||||
* @returns {{host: string, port: number, origin: string}|null} null when the
|
||||
* address is malformed or not local. Callers must treat null as a 400 and
|
||||
* must not fall back to a default host — a tap with no valid address has
|
||||
* nothing to read.
|
||||
*/
|
||||
export function parseTapAddress(raw) {
|
||||
const value = String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!value || value.length > 263) return null;
|
||||
// Reject a scheme, credentials, path, or query outright rather than
|
||||
// stripping them: each is a sign the caller meant a URL, not an address,
|
||||
// and quietly repairing it is how a parser ends up disagreeing with the
|
||||
// thing that later builds the request.
|
||||
if (/[/\\?#@]/.test(value) || value.includes('://')) return null;
|
||||
|
||||
const colon = value.lastIndexOf(':');
|
||||
if (colon <= 0 || colon === value.length - 1) return null;
|
||||
const host = value.slice(0, colon);
|
||||
const portText = value.slice(colon + 1);
|
||||
|
||||
if (!/^\d{1,5}$/.test(portText)) return null;
|
||||
const port = Number(portText);
|
||||
if (port < 1 || port > MAX_PORT) return null;
|
||||
|
||||
const hostOk =
|
||||
host === 'localhost' || MDNS_RE.test(host) || isLocalIpv4(host);
|
||||
if (!hostOk) return null;
|
||||
|
||||
return { host, port, origin: `http://${host}:${port}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an absolute upstream URL for a tap request.
|
||||
*
|
||||
* The path is supplied by the tap module, never by the client, so this only
|
||||
* has to guarantee the two halves join predictably.
|
||||
*
|
||||
* @param {{origin: string}} address from {@link parseTapAddress}.
|
||||
* @param {string} path absolute path beginning with `/`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tapUrl(address, path) {
|
||||
if (!address?.origin)
|
||||
throw new TypeError('tapUrl: address must come from parseTapAddress');
|
||||
if (typeof path !== 'string' || !path.startsWith('/')) {
|
||||
throw new TypeError('tapUrl: path must be an absolute path');
|
||||
}
|
||||
return `${address.origin}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// src/data/tapAddress.test.mjs
|
||||
// Contract tests for the shared receiver-tap address check. Pure — no server.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isLocalIpv4, parseTapAddress, tapUrl } from './tapAddress.js';
|
||||
|
||||
test('parseTapAddress: accepts loopback and every RFC1918 range', () => {
|
||||
for (const address of [
|
||||
'127.0.0.1:8080',
|
||||
'10.0.0.5:1234',
|
||||
'172.16.0.1:80',
|
||||
'172.31.255.254:65535',
|
||||
'192.168.1.1:8080',
|
||||
'localhost:8080',
|
||||
'rayhunter.local:8080',
|
||||
'dump1090.lan.local:30003',
|
||||
]) {
|
||||
assert.ok(parseTapAddress(address), `${address} should be accepted`);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: rejects public addresses — a tap reads your own network', () => {
|
||||
for (const address of [
|
||||
'8.8.8.8:80',
|
||||
'1.1.1.1:443',
|
||||
'93.184.216.34:80',
|
||||
'example.com:80',
|
||||
'api.wigle.net:443',
|
||||
// 172.15 and 172.32 sit just outside 172.16/12.
|
||||
'172.15.0.1:80',
|
||||
'172.32.0.1:80',
|
||||
// 192.167/192.169 bracket 192.168/16.
|
||||
'192.167.1.1:80',
|
||||
'192.169.1.1:80',
|
||||
]) {
|
||||
assert.equal(
|
||||
parseTapAddress(address),
|
||||
null,
|
||||
`${address} should be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: rejects link-local, so the cloud metadata address cannot be reached', () => {
|
||||
assert.equal(parseTapAddress('169.254.169.254:80'), null);
|
||||
assert.equal(parseTapAddress('169.254.1.1:80'), null);
|
||||
});
|
||||
|
||||
test('parseTapAddress: malformed dotted-quads are rejected, not treated as private', () => {
|
||||
// The trap this module exists to avoid: a classifier that reports garbage as
|
||||
// "not public" would let these through under an is-it-local test.
|
||||
for (const address of [
|
||||
'999.1.1.1:80',
|
||||
'10.0.0:80',
|
||||
'10.0.0.1.5:80',
|
||||
'10.0.0.256:80',
|
||||
'-1.0.0.1:80',
|
||||
]) {
|
||||
assert.equal(
|
||||
parseTapAddress(address),
|
||||
null,
|
||||
`${address} should be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: rejects anything shaped like a URL rather than repairing it', () => {
|
||||
for (const address of [
|
||||
'http://127.0.0.1:8080',
|
||||
'127.0.0.1:8080/api/status',
|
||||
'127.0.0.1:8080?x=1',
|
||||
'127.0.0.1:8080#f',
|
||||
'[email protected]:8080',
|
||||
'127.0.0.1:8080\\x',
|
||||
]) {
|
||||
assert.equal(
|
||||
parseTapAddress(address),
|
||||
null,
|
||||
`${address} should be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: rejects a missing, empty, or out-of-range port', () => {
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'127.0.0.1:',
|
||||
'127.0.0.1:0',
|
||||
'127.0.0.1:65536',
|
||||
'127.0.0.1:abc',
|
||||
':8080',
|
||||
]) {
|
||||
assert.equal(
|
||||
parseTapAddress(address),
|
||||
null,
|
||||
`${address} should be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: rejects IPv6 explicitly rather than half-supporting it', () => {
|
||||
assert.equal(parseTapAddress('[::1]:8080'), null);
|
||||
assert.equal(parseTapAddress('::1:8080'), null);
|
||||
assert.equal(parseTapAddress('[fe80::1%eth0]:8080'), null);
|
||||
});
|
||||
|
||||
test('parseTapAddress: handles non-string and absent input without throwing', () => {
|
||||
for (const value of [null, undefined, 0, {}, [], NaN, true]) {
|
||||
assert.equal(parseTapAddress(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseTapAddress: normalizes case and surrounding whitespace', () => {
|
||||
const parsed = parseTapAddress(' RayHunter.LOCAL:8080 ');
|
||||
assert.deepEqual(parsed, {
|
||||
host: 'rayhunter.local',
|
||||
port: 8080,
|
||||
origin: 'http://rayhunter.local:8080',
|
||||
});
|
||||
});
|
||||
|
||||
test('isLocalIpv4: boundaries of each allowed range', () => {
|
||||
assert.ok(isLocalIpv4('127.255.255.255'));
|
||||
assert.ok(isLocalIpv4('10.255.255.255'));
|
||||
assert.ok(isLocalIpv4('172.16.0.0'));
|
||||
assert.ok(isLocalIpv4('172.31.255.255'));
|
||||
assert.ok(!isLocalIpv4('172.15.255.255'));
|
||||
assert.ok(!isLocalIpv4('172.32.0.0'));
|
||||
assert.ok(!isLocalIpv4('126.255.255.255'));
|
||||
assert.ok(!isLocalIpv4('128.0.0.1'));
|
||||
});
|
||||
|
||||
test('tapUrl: joins a parsed address with a module-supplied path', () => {
|
||||
const address = parseTapAddress('192.168.1.1:8080');
|
||||
assert.equal(
|
||||
tapUrl(address, '/api/qmdl-manifest'),
|
||||
'http://192.168.1.1:8080/api/qmdl-manifest',
|
||||
);
|
||||
});
|
||||
|
||||
test('tapUrl: refuses an unparsed address or a relative path', () => {
|
||||
const address = parseTapAddress('192.168.1.1:8080');
|
||||
assert.throws(() => tapUrl(null, '/x'), TypeError);
|
||||
assert.throws(() => tapUrl({ origin: '' }, '/x'), TypeError);
|
||||
assert.throws(() => tapUrl(address, 'api/status'), TypeError);
|
||||
assert.throws(() => tapUrl(address, ''), TypeError);
|
||||
});
|
||||
Reference in new issue
Block a user