Skip to content

Commit 387ab43

Browse files
committed
[fix.security.rho.D8+] over Tailscale the page loaded and the daemon said 403, which the UI renders as "backend offline"
Measured from this box: http://100.78.155.10:1338/ → 200, and http://100.78.155.10:8901/health → 403. The Host allowlist derived this box's addresses from `gethostname()` plus the source address of the DEFAULT ROUTE — so the LAN address was in the set and the Tailscale address never was. A security refusal was therefore wearing the costume of a dead service, and that is exactly the report Ivan filed from the Mac earlier ("为什么显示 backend offline"). Now enumerates every address on every interface (`ip -o addr`), v4 and v6, with v6 bracketed the way a Host header carries it, plus a documented DIRAC_EXTRA_HOSTS escape hatch for NAMES — MagicDNS names and reverse-proxy hostnames cannot be enumerated from an interface, and a wildcard is not an acceptable substitute for a list. THIS DOES NOT WIDEN THE THREAT MODEL, and the reasoning is in the code: the rebinding attack this check exists to stop is a page resolving ITS OWN hostname to one of this box's addresses. Allowing all of the box's own addresses is precisely the intent; what must never be allowed is a name that is not ours. Verified in BOTH directions, because a widened allowlist that no longer refuses anything would be worse than the bug: Tailscale/LAN/loopback all 200, and `-H 'Host: evil.example.com'` still 403. The 403 is now DIAGNOSABLE from the server side. The response stays opaque — echoing the allowlist would hand an attacker this box's addresses — but the log names the rejected Host and prints the exact fix: [host] REFUSED Host='evil.example.com' — not one of this box's 14 known names/addresses. If this is a real interface or DNS name, add it: DIRAC_EXTRA_HOSTS=evil.example.com A refusal that names neither the actor nor the way forward is the product rule this repo already applies to chemistry errors; there was no reason for the security path to be exempt. The interface-enumeration failure is loud for the same reason: silently falling back to the old narrow set would restore the outage-shaped 403 with nothing on screen to explain it.
1 parent d02b496 commit 387ab43

1 file changed

Lines changed: 48 additions & 1 deletion

File tree

‎backend/field_server.py‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,42 @@ def _allowed_hosts() -> set[str]:
13741374
s.close()
13751375
except OSError:
13761376
pass
1377+
1378+
# EVERY address on EVERY interface, because the routing trick above only
1379+
# finds the DEFAULT route's source address. Measured symptom: the Mac
1380+
# reached the page over Tailscale (100.78.155.10:1338 → 200) and the fields
1381+
# daemon answered 403, so the UI said "backend offline" — a security refusal
1382+
# wearing the costume of a dead service. The default route goes out the LAN
1383+
# interface, so the Tailscale address was never in this set, and
1384+
# gethostname() does not resolve to it either.
1385+
#
1386+
# This does NOT widen the threat model: the rebinding attack this check
1387+
# exists to stop is a page resolving ITS OWN hostname to one of this box's
1388+
# addresses. Allowing all of the box's own addresses is precisely the
1389+
# intent; what must never be allowed is a name that is not ours.
1390+
try:
1391+
import subprocess
1392+
out = subprocess.run(['ip', '-o', 'addr', 'show'],
1393+
capture_output=True, text=True, timeout=5)
1394+
for line in out.stdout.splitlines():
1395+
parts = line.split()
1396+
if len(parts) >= 4 and parts[2] in ('inet', 'inet6'):
1397+
addr = parts[3].split('/')[0]
1398+
hosts.add(addr)
1399+
if parts[2] == 'inet6':
1400+
hosts.add(f'[{addr}]') # Host headers bracket v6
1401+
except Exception as e: # noqa: BLE001
1402+
# LOUD: a silent failure here means every non-default-route client gets
1403+
# a 403 that reads as an outage, which is the bug this block fixes.
1404+
print(f'[host] could not enumerate interfaces ({e}) — only localhost and '
1405+
f'the default-route address are allowed; a client on another '
1406+
f'interface (Tailscale, a second NIC) will see 403', flush=True)
1407+
1408+
# Names cannot be enumerated from an interface — MagicDNS names, a LAN
1409+
# hostname, a reverse-proxy name. One documented escape hatch instead of
1410+
# a wildcard.
1411+
extra = os.environ.get('DIRAC_EXTRA_HOSTS', '')
1412+
hosts.update(h.strip() for h in extra.split(',') if h.strip())
13771413
return hosts
13781414

13791415

@@ -1384,7 +1420,18 @@ def _allowed_hosts() -> set[str]:
13841420
class Handler(BaseHTTPRequestHandler):
13851421
def _host_ok(self) -> bool:
13861422
host = (self.headers.get('Host') or '').rsplit(':', 1)[0]
1387-
return host in ALLOWED_HOSTS
1423+
ok = host in ALLOWED_HOSTS
1424+
if not ok:
1425+
# The RESPONSE stays opaque — echoing the allowlist would hand an
1426+
# attacker this box's addresses. The LOG is where the diagnosis
1427+
# lives, because the 403 is otherwise indistinguishable from a dead
1428+
# daemon from the client's side, and that cost a Mac-side debugging
1429+
# session already.
1430+
print(f'[host] REFUSED Host={host!r} — not one of this box\'s '
1431+
f'{len(ALLOWED_HOSTS)} known names/addresses. If this is a real '
1432+
f'interface or DNS name, add it: DIRAC_EXTRA_HOSTS={host}',
1433+
flush=True)
1434+
return ok
13881435

13891436
def _cors_origin(self) -> str | None:
13901437
origin = self.headers.get('Origin')

0 commit comments

Comments
 (0)