Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/remote-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Under `local` each runtime — a one-time link, or the persistent Burrow — is

- **The attempt's UDP socket binds the one allowed address when exactly one is present**: a single interface holds every address in the allowed networks, loopback and link-local aside, and exactly one in its preferred family, IPv4 over IPv6. **Otherwise it listens on every interface** (`docs/specs/remote-security-model.md` -> "Direct path"), and the level restricts the path, not the listener. Chosen per attempt (rationale).
- **Must strip every candidate outside the allowed networks from the Burrow's answer**, and send a default address outside them as `0.0.0.0`. **An answer left with no candidate refuses the attempt.**
- **Must strip the phone's offer the same way before the Burrow applies it**, a hostname or mDNS name included, so its ICE agent sends no check and makes no lookup toward an address the level does not hold. **An offer left with no candidate is still answered**: the phone's checks reach the answer's candidates, and the pair forms peer-reflexive (rationale).
- **Must strip the phone's offer the same way before the Burrow applies it**, a hostname, mDNS name, or unreadable candidate included, so its ICE agent sends no check and makes no lookup toward an address the level does not hold. **An offer left with no candidate is still answered**: the phone's checks reach the answer's candidates, and the pair forms peer-reflexive (rationale).
- **Must check the selected candidate pair on the Burrow before its channel reports open**, and again while it is open and `connected` — on every ICE or connection state change, and every `DIRECT_PATH_RECHECK_MS` (rationale): both ends parse as IP addresses — IPv4-mapped IPv6 matching its IPv4 range — each inside an allowed CIDR. **A hostname, an mDNS name, or a pair the stack will not report refuses**, and a frame arriving before the open is checked first; once open, a reading with no pair is left to the connection's own state (rationale).
- **Never trust SDP candidates, Hosted-observed addresses, or Client claims** as path evidence; only the Burrow's own ICE agent answers (rationale).
- **A refusal is a violation**: it ends the session `network-not-allowed` (`docs/specs/one-time.md` -> "Burrow runtime"), switched or not.
Expand Down
27 changes: 27 additions & 0 deletions lib/src/host/remote/local-networks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,33 @@ describe('localNetworksPath', () => {
expect(candidatesIn(localNetworksPath(['10.0.0.0/8']).acceptRemote(offer))).toEqual([]);
});

it('strips an offer’s off-network candidate however its whitespace and line endings are written', () => {
// Each of these is a candidate toward 203.0.113.7 that the native stack still parses.
const offer = [
'v=0',
'c=IN IP4\t203.0.113.7',
'a=candidate:1 1 udp 2113937151 192.168.86.23 51234 typ host',
'a=candidate:2 1 udp 2113937151 203.0.113.7 51236 typ host',
'a=candidate:3 1 udp 2113937151\t203.0.113.7 51237 typ host',
'a=candidate:4 1 udp 2113937151 192.168.86.23 51238 typ host\na=candidate:5 1 udp 2113937151 203.0.113.7 51239 typ host',
'a=candidate:6 1 udp 2113937151 192.168.86.23 51240 typ host\ra=candidate:7 1 udp 2113937151 203.0.113.7 51241 typ host',
'a=candidate:8 1 udp',
// U+00A0 is not whitespace to the native parser, which reads 203.0.113.7 as this address.
'a=candidate:9\u00a0x\u00a0y\u00a0z\u00a0192.168.86.23 1 udp 2113937151 203.0.113.7 51242 typ host',
'',
].join('\r\n');

const accepted = localNetworksPath(LAN).acceptRemote(offer);
expect(accepted).not.toContain('203.0.113.7');
expect(accepted).not.toContain('candidate:8');
expect(candidatesIn(accepted)).toEqual([
'a=candidate:1 1 udp 2113937151 192.168.86.23 51234 typ host',
'a=candidate:4 1 udp 2113937151 192.168.86.23 51238 typ host',
'a=candidate:6 1 udp 2113937151 192.168.86.23 51240 typ host',
]);
expect(accepted).toContain('\r\nc=IN IP4 0.0.0.0\r\n');
});

it('reports the offer’s first public candidate, whatever the allowed networks, as a diagnostic', () => {
// A phone on cellular: its carrier's CGNAT host, a link-local, a ULA, an
// mDNS name, then the srflx Cloudflare STUN gave it — and another after.
Expand Down
34 changes: 21 additions & 13 deletions lib/src/host/remote/local-networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,32 @@ export function bindAddressFor(
return family.length === 1 ? family[0]! : null;
}

/** `c=` and `a=candidate` lines, with the address each names. */
const CONNECTION_LINE = /^c=IN IP[46] (\S+)$/;
const CANDIDATE_LINE = /^a=candidate:\S+ \S+ \S+ \S+ (\S+) /;

/**
* `sdp` with every candidate outside `inAllowed` removed and a default address
* outside it written as `0.0.0.0`, and how many candidates are left.
* `sdp` with every candidate outside `inAllowed`, or with no address to read,
* removed and a default address outside it written as `0.0.0.0`, and how many
* candidates are left. Lines end at any of CRLF, LF, or CR and fields at the
* C-locale whitespace libdatachannel splits on, never JavaScript's wider `\s`,
* so no line the native parser reads as a candidate escapes this one; lines
* rejoin with the first ending found.
*/
function keepAllowed(sdp: string, inAllowed: (address: string) => boolean): { sdp: string; candidates: number } {
const eol = sdp.includes('\r\n') ? '\r\n' : '\n';
const eol = /\r\n|\r|\n/.exec(sdp)?.[0] ?? '\n';
let candidates = 0;
const lines: string[] = [];
for (const line of sdp.split(eol)) {
const candidate = CANDIDATE_LINE.exec(line);
if (candidate) {
if (!inAllowed(candidate[1]!)) continue;
for (const line of sdp.split(/\r\n|\r|\n/)) {
const fields = line.split(/[ \t\v\f]+/).filter((field) => field !== '');
if (/^a=candidate:/i.test(fields[0] ?? '')) {
const address = fields[4];
if (!address || !inAllowed(address)) continue;
candidates += 1;
} else if (/^c=/i.test(fields[0] ?? '')) {
const address = fields[2];
if (!address || !inAllowed(address)) {
lines.push('c=IN IP4 0.0.0.0');
continue;
}
}
const connection = CONNECTION_LINE.exec(line);
lines.push(connection && !inAllowed(connection[1]!) ? 'c=IN IP4 0.0.0.0' : line);
lines.push(line);
}
return { sdp: lines.join(eol), candidates };
}
Expand All @@ -77,6 +83,8 @@ const isPrivateAddress = allowedAddressTest([
'fe80::/10',
]);

const CANDIDATE_LINE = /^a=candidate:\S+ \S+ \S+ \S+ (\S+) /;

/**
* The first candidate of `sdp` that is an IP literal outside every private
* range — a phone's server-reflexive candidate, most often — and outside
Expand Down
Loading