File size: 4,229 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { timingSafeEqual } from "node:crypto";
import { classifyHostLocality } from "./routeGuard";

/**
 * Resolve the real peer IP from the trusted `<token>|<ip>` stamp that the custom
 * Node server writes into PEER_IP_HEADER (see scripts/dev/peer-stamp.mjs). Returns
 * the IP ONLY when the token constant-time-matches this process's stamp token;
 * any other value (no stamp, wrong/forged token, missing separator, empty IP)
 * returns null.
 *
 * Pure + dependency-free so the auth boundary is directly unit-testable.
 *
 * SECURITY: this is the ONLY trustworthy locality signal in the Next middleware
 * runtime (which has no socket). Never derive locality from the Host header β€” it
 * is fully client-controlled, so `Host: 127.0.0.1` from a remote attacker would
 * otherwise bypass the LOCAL_ONLY gate guarding spawn-capable routes.
 */
export function resolveStampedPeer(
  headerValue: string | null,
  token: string | undefined
): string | null {
  if (!headerValue || !token) return null;
  const sep = headerValue.indexOf("|");
  if (sep <= 0) return null;
  const provided = headerValue.slice(0, sep);
  const ip = headerValue.slice(sep + 1);
  if (!ip) return null;
  if (provided.length !== token.length) return null;
  try {
    if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return null;
  } catch {
    return null;
  }
  return ip;
}

/**
 * Resolve the trusted "request arrived via a reverse proxy" marker stamped by
 * the custom Node server (`scripts/dev/peer-stamp.mjs::stampPeerIp`). The stamp
 * is `<token>|1` when forwarding headers (`x-forwarded-for` / `x-real-ip`) were
 * present on the inbound TCP request, and `<token>|0` otherwise.
 *
 * Returns true ONLY when the token constant-time-matches this process's stamp
 * token AND the payload is exactly "1". Any other value β€” no stamp, forged
 * token, "0", junk β€” returns false (the safe default: assume no proxy hop).
 *
 * SECURITY: paired with `resolveStampedPeer()` to close the upstream
 * decolua/9router da667836 vulnerability β€” when OmniRoute itself runs behind
 * an external reverse proxy (nginx / Caddy / Cloudflare Tunnel),
 * `req.socket.remoteAddress` is the proxy hop (usually 127.0.0.1), not the
 * end-user. Without this signal, `classifyHostLocality()` would return
 * "loopback" for every remote caller arriving via the proxy, granting access
 * to the LOCAL_ONLY tier that gates spawn-capable routes (Hard Rules #15 +
 * #17). Consume via `classifyStampedPeerLocality()` below.
 */
export function resolveStampedViaProxy(
  headerValue: string | null,
  token: string | undefined
): boolean {
  if (!headerValue || !token) return false;
  const sep = headerValue.indexOf("|");
  if (sep <= 0) return false;
  const provided = headerValue.slice(0, sep);
  const payload = headerValue.slice(sep + 1);
  if (provided.length !== token.length) return false;
  try {
    if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return false;
  } catch {
    return false;
  }
  return payload === "1";
}

/**
 * The trusted locality verdict consumed by the LOCAL_ONLY route guard. Wraps
 * `resolveStampedPeer()` + `resolveStampedViaProxy()` + `classifyHostLocality()`
 * so the pipeline has a single boundary helper:
 *
 *   1. Resolve the real peer IP from PEER_IP_HEADER (or fail closed β†’ remote).
 *   2. If the via-proxy marker is present, the loopback / private-LAN socket
 *      is the proxy hop, not the end-user β€” downgrade to "remote".
 *      (Public-IP sockets are already remote, so the marker is a no-op there.)
 *   3. Otherwise classify the raw IP normally (loopback / lan / remote).
 *
 * Pure; both header values are token-validated, so an attacker who knows the
 * header names but not the per-process token cannot influence the verdict in
 * any direction.
 */
export function classifyStampedPeerLocality(
  peerHeader: string | null,
  viaProxyHeader: string | null,
  token: string | undefined
): "loopback" | "lan" | "remote" {
  const ip = resolveStampedPeer(peerHeader, token);
  const viaProxy = resolveStampedViaProxy(viaProxyHeader, token);
  const locality = classifyHostLocality(ip);
  if (viaProxy && locality !== "remote") return "remote";
  return locality;
}