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
80 changes: 76 additions & 4 deletions bin/fm-bridge-view.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
MAILBOX_PORT = 8765
MAILBOX_LAUNCHD = "com.firstmate.glasses-voice-mailbox"
UPLOAD_MAX_BYTES = 15 * 1024 * 1024
BODY_DRAIN_TIMEOUT_SECONDS = 1.0
UPLOAD_RATE_LIMIT = 30
UPLOAD_RATE_WINDOW_SECONDS = 3600
UPLOAD_FIELD = "photo"
Expand Down Expand Up @@ -1181,6 +1182,10 @@ def expected_origins(self) -> set[str]:
class BridgeHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"

def handle_one_request(self) -> None:
self._body_drained = False
super().handle_one_request()

def log_message(self, fmt: str, *args: Any) -> None:
log_path = bridge_dir(STATE.home) / "bridge.log" if STATE else None
line = "%s - %s\n" % (self.log_date_time_string(), fmt % args)
Expand All @@ -1201,11 +1206,75 @@ def _host_ok(self) -> bool:
return header.lower() in STATE.expected_hosts() or host in STATE.expected_hosts()

def _origin_ok(self) -> bool:
origin = (self.headers.get("Origin") or "").strip().rstrip("/")
return origin.lower() in STATE.expected_origins()
# When Origin is present it must match the expected Serve origin exactly.
# iPhone Safari omits Origin on same-origin form POST, so absence is not
# a CSRF failure by itself. Accept an absent Origin only when Fetch
# Metadata (Sec-Fetch-Site same-origin or none) or an https Referer to
# the expected host independently proves same-origin.
origin = (self.headers.get("Origin") or "").strip()
if origin:
return origin.rstrip("/").lower() in STATE.expected_origins()
site = (self.headers.get("Sec-Fetch-Site") or "").strip().lower()
if site in {"same-origin", "none"}:
return True
referer = (self.headers.get("Referer") or "").strip()
if not referer:
return False
parsed = urlparse(referer)
if parsed.scheme.lower() != "https":
return False
host = (parsed.netloc or "").strip().rstrip("/").lower()
expected = {item.lower() for item in STATE.expected_hosts()}
return host in expected

def _discard_body(self) -> List[Tuple[str, str]]:
# Early 403/404/413 returns must drain the unread POST body. Leaving it
# on a keep-alive socket makes the next parse treat passcode=... as a
# request line (400 / 501). Close instead when the declared length is
# missing or larger than any accepted upload.
if getattr(self, "_body_drained", False):
return []
self._body_drained = True
raw = (self.headers.get("Content-Length") or "").strip()
if not raw.isdigit():
return [("Connection", "close")] if self.command == "POST" else []
length = int(raw)
if length > UPLOAD_MAX_BYTES:
return [("Connection", "close")]
remaining = length
deadline = time.monotonic() + BODY_DRAIN_TIMEOUT_SECONDS
previous_timeout = self.connection.gettimeout()
try:
while remaining:
timeout = deadline - time.monotonic()
if timeout <= 0:
self.close_connection = True
return [("Connection", "close")]
self.connection.settimeout(timeout)
chunk = self.rfile.read1(min(remaining, 65536))
if not chunk:
self.close_connection = True
return [("Connection", "close")]
remaining -= len(chunk)
except (TimeoutError, socket.timeout):
self.close_connection = True
return [("Connection", "close")]
finally:
self.connection.settimeout(previous_timeout)
return []

def _with_drained_body(
self, extra: Optional[List[Tuple[str, str]]]
) -> List[Tuple[str, str]]:
merged: List[Tuple[str, str]] = list(extra or [])
for key, value in self._discard_body():
if not any(existing[0].lower() == key.lower() for existing in merged):
merged.append((key, value))
return merged

def _send(self, code: int, body: bytes, content_type: str, extra: Optional[List[Tuple[str, str]]] = None) -> None:
nonce = secrets.token_urlsafe(16)
extra = self._with_drained_body(extra)
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
Expand All @@ -1216,20 +1285,21 @@ def _send(self, code: int, body: bytes, content_type: str, extra: Optional[List[
self.send_header(key, csp(nonce))
else:
self.send_header(key, value)
for key, value in extra or []:
for key, value in extra:
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)

def _html(self, code: int, renderer, extra: Optional[List[Tuple[str, str]]] = None, **kwargs: Any) -> None:
nonce = secrets.token_urlsafe(16)
extra = self._with_drained_body(extra)
body = renderer(nonce, **kwargs).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
for key, value in security_headers(nonce):
self.send_header(key, value)
for key, value in extra or []:
for key, value in extra:
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
Expand Down Expand Up @@ -1313,6 +1383,7 @@ def _handle_upload(self) -> None:
self._json_error(429, "too many uploads")
return
raw = self.rfile.read(length) if length else b""
self._body_drained = True
if len(raw) > UPLOAD_MAX_BYTES:
self._json_error(413, "too large")
return
Expand Down Expand Up @@ -1361,6 +1432,7 @@ def do_POST(self) -> None: # noqa: N802
self._send(413, b"too large\n", "text/plain; charset=utf-8")
return
raw = self.rfile.read(length) if length else b""
self._body_drained = True
if parsed.path == "/logout":
STATE.sessions.revoke(self._session())
self._html(303, login_html, extra=[("Set-Cookie", self._clear_cookie()), ("Location", "/")])
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-bridge-view.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# Environment:
# FM_HOME private Firstmate home; defaults to this repository root
# FM_BRIDGE_VIEW_PORT dedicated IPv4 loopback port; defaults to 8766
# FM_BRIDGE_VIEW_HOST expected Serve MagicDNS hostname (Host/Origin checks)
# FM_BRIDGE_VIEW_HOST expected Serve MagicDNS hostname (Host/POST CSRF checks)
# FM_BRIDGE_VIEW_TEST set to 1 only in the behavior suite
set -eu

Expand Down
9 changes: 7 additions & 2 deletions docs/bridge-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ The salted scrypt hash lives at `bridge/passcode.hash` inside a mode-0700 `bridg
Sessions live in `bridge/sessions.json`.
Logs go to `bridge/bridge.log`, not into `state/`.

`config/bridge-view` may contain `host=<magicdns-name>` for Host and login Origin checks.
`config/bridge-view` may contain `host=<magicdns-name>` for Host and POST CSRF checks.
`FM_BRIDGE_VIEW_HOST` overrides that file.
Login and other POST writes require CSRF proof on top of Host.
When the browser sends `Origin`, it must match `https://<host>` (or that host on port 443) exactly.
iPhone Safari omits `Origin` on this same-origin form POST, so a missing Origin is accepted only when `Sec-Fetch-Site` is `same-origin` or `none`, or when `Referer` is an `https` URL whose host matches the expected Serve name.
A present but wrong Origin is always rejected.
Error responses drain a bounded unread request body (or close the connection) so a keep-alive socket does not treat leftover POST bytes as the next request line.

Initialize once:

Expand Down Expand Up @@ -86,7 +91,7 @@ Last-good on the server cannot save a tab that never hears back.
Login and logout POSTs remain the authentication writes.
The only other write path is an authenticated photo drop.

`POST /upload` requires the same session cookie plus Host and Origin checks as login.
`POST /upload` requires the same session cookie plus Host and CSRF checks as login.
The body may be `multipart/form-data` with a `photo` file field, or a raw image body whose `Content-Type` is one of `image/jpeg`, `image/png`, `image/webp`, `image/heic`, or `image/heif`.
The server accepts those declared types only after magic-byte sniffing (JPEG, PNG, WebP, HEIC/HEIF); a matching header is not enough.
The request is capped at 15 MB and returns 413 when larger or 415 when the type or magic bytes are not an allowed image.
Expand Down
177 changes: 174 additions & 3 deletions tests/fm-bridge-view.test.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Behavior tests for the phone bridge view: loopback bind, Tailscale Funnel
# refusal, Host/Origin checks, session cookie isolation from port 8765, read-only
# snapshot subprocess, away-mode passive refresh, auth headers, and authenticated
# photo drops into the quarantined inbox.
# refusal, Host/Origin checks including Safari form POSTs that omit Origin,
# session cookie isolation from port 8765, read-only snapshot subprocess,
# away-mode passive refresh, auth headers, authenticated photo drops into
# the quarantined inbox, and keep-alive body drain after early error returns.
set -u

# shellcheck source=tests/lib.sh
Expand Down Expand Up @@ -369,6 +370,175 @@ PY
pass "passcode login, security headers, host-only cookie, and revoke all work"
}

test_safari_login_without_origin_and_keepalive_body_drain() {
local home fakebin port pass hdr body cookie output
home=$(make_home safari-login)
fakebin=$(make_fakebin "$home")
pass=$(init_passcode "$home")
port=$(start_bridge "$home" "$fakebin")

hdr=$home/safari.hdr; body=$home/safari.body
curl_bridge "$port" /login "$hdr" "$body" \
--header "Sec-Fetch-Site: same-origin" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "303" \
"Safari same-origin login without Origin must succeed"
cookie=$(awk 'tolower($1)=="set-cookie:" {print substr($0, index($0,$2)); exit}' "$hdr")
[ -n "$cookie" ] || fail "Safari-shaped login did not set a session cookie"

hdr=$home/safari-none.hdr; body=$home/safari-none.body
curl_bridge "$port" /login "$hdr" "$body" \
--header "Sec-Fetch-Site: none" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "303" \
"absent Origin with Sec-Fetch-Site none must succeed"

hdr=$home/safari-referer.hdr; body=$home/safari-referer.body
curl_bridge "$port" /login "$hdr" "$body" \
--header "Referer: $ORIGIN/" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "303" \
"absent Origin with https Referer to the expected host must succeed"

hdr=$home/no-proof.hdr; body=$home/no-proof.body
curl_bridge "$port" /login "$hdr" "$body" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "403" \
"absent Origin without same-origin proof must be rejected"

hdr=$home/http-referer.hdr; body=$home/http-referer.body
curl_bridge "$port" /login "$hdr" "$body" \
--header "Referer: http://$HOST_NAME/" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "403" \
"absent Origin with http Referer must be rejected"

hdr=$home/wrong-origin.hdr; body=$home/wrong-origin.body
curl_bridge "$port" /login "$hdr" "$body" \
--header "Origin: https://evil.example" \
--header "Sec-Fetch-Site: same-origin" \
--data "passcode=$pass"
assert_contains "$(head -n 1 "$hdr")" "403" \
"present but wrong Origin must be rejected even with Sec-Fetch-Site"

output=$(python3 - "$HOST_NAME" "$port" "$pass" <<'PY'
import socket
import sys
import threading
import time

host, port, password = sys.argv[1], int(sys.argv[2]), sys.argv[3]
body = ("passcode=" + password).encode("utf-8")


def recv_http(sock):
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if b"\r\n\r\n" not in data:
raise SystemExit("incomplete HTTP response: %r" % (data[:200],))
header, rest = data.split(b"\r\n\r\n", 1)
length = 0
for line in header.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
length = int(line.split(b":", 1)[1].strip())
while len(rest) < length:
chunk = sock.recv(4096)
if not chunk:
break
rest += chunk
return header, rest[:length]


sock = socket.create_connection(("127.0.0.1", port), timeout=8)
try:
sock.sendall(
(
"GET / HTTP/1.1\r\n"
"Host: %s\r\n"
"Connection: keep-alive\r\n"
"\r\n" % host
).encode("ascii")
)
header, _ = recv_http(sock)
status = header.split(b"\r\n", 1)[0]
if b" 200 " not in status:
raise SystemExit("expected initial 200, got %r" % (status,))
sock.sendall(
(
"POST /login HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: %d\r\n"
"Connection: keep-alive\r\n"
"\r\n" % (host, len(body))
).encode("ascii")
+ body
)
header, _ = recv_http(sock)
status = header.split(b"\r\n", 1)[0]
if b" 403 " not in status:
raise SystemExit("expected 403 on unproven login, got %r" % (status,))
sock.sendall(
(
"GET / HTTP/1.1\r\n"
"Host: %s\r\n"
"Connection: close\r\n"
"\r\n" % host
).encode("ascii")
)
header, _ = recv_http(sock)
status = header.split(b"\r\n", 1)[0]
if b" 400 " in status or b" 501 " in status:
raise SystemExit("keep-alive follow-up parsed as garbage: %r" % (status,))
if b" 200 " not in status:
raise SystemExit("expected 200 on drained follow-up GET, got %r" % (status,))
finally:
sock.close()

sock = socket.create_connection(("127.0.0.1", port), timeout=4)
try:
sock.sendall(
(
"POST /login HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: 4096\r\n"
"Connection: keep-alive\r\n"
"\r\n"
"passcode=x" % host
).encode("ascii")
)
started = time.monotonic()

def drip_body():
try:
for _ in range(12):
time.sleep(0.4)
sock.sendall(b"x")
except OSError:
pass

threading.Thread(target=drip_body, daemon=True).start()
header, _ = recv_http(sock)
elapsed = time.monotonic() - started
status = header.split(b"\r\n", 1)[0]
if b" 403 " not in status:
raise SystemExit("expected 403 on incomplete unproven login, got %r" % (status,))
if b"connection: close" not in header.lower():
raise SystemExit("incomplete body response did not close the connection")
if elapsed >= 2.5:
raise SystemExit("slow-drip body held the handler for %.2f seconds" % elapsed)
finally:
sock.close()
PY
) || fail "keep-alive body after 403 was not drained: $output"
pass "Safari-shaped login without Origin works and 403 drains the body"
}

test_snapshot_subprocess_does_not_write_fleet_state() {
local home fakebin port before after hdr body spies
home=$(make_home readonly)
Expand Down Expand Up @@ -784,6 +954,7 @@ test_funnel_off_legacy_serve_output_starts
test_unverifiable_funnel_refuses_to_serve
test_lan_and_unauthorized_hosts_are_rejected
test_auth_cookie_headers_and_isolation
test_safari_login_without_origin_and_keepalive_body_drain
test_snapshot_subprocess_does_not_write_fleet_state
test_snapshot_output_is_bounded_during_capture
test_snapshot_requests_all_in_flight_rows
Expand Down
Loading