Files
gods-eye-view/src/data/tapAddress.test.mjs
T
25491f7e33 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]>
2026-09-22 19:22:55 -05:00

148 lines
4.3 KiB
JavaScript

// 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);
});