Skip to content

fix(engine): reach model endpoints through fake-IP tunnels - #469

Merged
AprilNEA merged 1 commit into
masterfrom
fix/model-probe-fake-ip
Aug 21, 2026
Merged

fix(engine): reach model endpoints through fake-IP tunnels#469
AprilNEA merged 1 commit into
masterfrom
fix/model-probe-fake-ip

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Symptom

Adding a LinkCode Gateway account failed with Model detection failed: Model detection cannot access private or non-routable addresses, while the endpoint itself was perfectly reachable.

Cause

resolvePublicEndpoint refused any hostname resolving into 198.18.0.0/15. On a machine running a TUN proxy in fake-IP mode that is every hostname: the resolver hands out a placeholder from RFC 2544's benchmarking range (Clash/mihomo/sing-box/Surge all mint from it) and the tunnel routes by that placeholder. The probe's SSRF blocklist and fake-IP addressing collide by construction.

Verified locally: system resolver 198.18.0.2, gateway.linkcode.ai198.18.16.15, real address via 1.1.1.1172.66.158.8. Connecting to the placeholder with SNI set to the real name returns the gateway's own 401, TLS authorized: true.

Change

The hand-rolled BlockList pair is replaced by @microsoft/antissrf (MIT, no runtime deps):

  • Enforcement moves into the agent's DNS lookup. The checked address is the one the socket connects to, so the rebinding gap stays closed without us hand-maintaining a resolve-then-pin invariant. resolvePublicEndpoint, the manual pin, and the manual host/servername juggling are deleted; the request is a normal hostname request again.
  • The range table is no longer ours. It tracks IANA registries and covers what the local table missed: cloud metadata, Azure wireserver (168.63.129.16/32), AS112, 6to4 relay, SRv6, 3fff::/20, and a wider Teredo range.
  • RFC-reserved ranges are re-permitted (2544 benchmarking, 5737/3849 documentation, plus sing-box's fc00::/18). No routable host may live there, so a name resolving into one can only be a local resolver's placeholder. Denying that class buys no protection and strands every user behind a tunnel. Real ULA (fd00::/8 — Tailscale, Docker) stays denied.
  • Plaintext HTTP no longer carries a secret. allowPlainTextHttp defaults to false, which closes a P1 raised in feat(desktop,agent-adapter): API-key / relay login on the signed-out agent card #350's review that was never implemented.

Verification

Live, through a fake-IP TUN, driving the real probeEndpointModels:

endpoint result
https://gateway.linkcode.ai/v1/models 401 invalid_api_key — reaches the endpoint
http://127.0.0.1:19533/v1/models refused (the daemon's own port)
http://169.254.169.254/latest/meta-data/ refused
http://192.168.1.1/models refused
http://gateway.linkcode.ai/v1/models refused (plaintext)

Also: 20 probe tests pass, pnpm check:ci clean, and the tsup-bundled daemon boots from dist to Daemon listener bound — the policy is constructed at import time, so only a real boot proves the CJS dependency survives bundling.

pnpm test has 3 pre-existing failures in release-artifact.test.ts, present on master without this change.

Not addressed

  • Catalog services still go through the same SSRF path even though their URLs are code constants — the trust layering is the root cause behind the symptom.
  • createKey still runs before the probe in add-flow.tsx, so a failed probe leaves an orphan gateway key.
  • A failed probe still hard-blocks account creation.

Replaces the hand-rolled address blocklist with @microsoft/antissrf, whose
policy is enforced inside the agent's DNS lookup and whose range table covers
cloud metadata, Azure wireserver, AS112 and SRv6 that the local table missed.
RFC-reserved ranges are re-permitted so a fake-IP tunnel's placeholder resolves
through, and plaintext HTTP no longer carries a secret.
Copilot AI lite review requested due to automatic review settings August 21, 2026 14:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The mechanism works — I extracted @microsoft/antissrf@1.0.0 from npm, ran the probe suite, and confirmed both gates (IP-literal and DNS-lookup) refuse loopback before a socket opens. But the test named for the address boundary proves the protocol boundary instead, so the rewrite's core claim is unverified in CI, and ExternalOnlyLatest quietly turns on a header the PR body doesn't mention.

Reviewed changes — the initial review of f382df6a, covering the swap from the hand-rolled BlockList pair to @microsoft/antissrf and the test rewrites that follow it.

  • Enforcement moves into the agentresolvePublicEndpoint, requestModelListAtAddress, and the local withAbort helper are deleted; requestPublicModelList(url, headers, signal?, agent?) is the sole transport entry and issues a normal hostname request, with the antissrf agent checking the address inside createConnection (IP literals) and a policy-wrapped lookup (names).
  • Range table outsourcedPolicyConfigOptions.ExternalOnlyLatest supplies 34 denied ranges; benchmarking, documentation, and fc00::/18 are re-permitted so a fake-IP tunnel's placeholder addresses resolve through.
  • Plaintext HTTP now refusedallowPlainTextHttp defaults to false in antissrf, so a secret can no longer ride an http:// probe.
  • Errors collapse to one stringnormalizeError maps any AntiSSRFError to 'Model detection only reaches public HTTPS endpoints'.
  • Tests — the address/scheme assertions are rewritten against requestPublicModelList and PROBE_POLICY; transport tests inject a bare new Agent() to bypass the policy and drive a loopback server.

Things I checked and found sound, so they need no attention: _isNetworkConnectionAllowed evaluates the allow list before the deny list, so the re-permits genuinely override ExternalOnlyLatest; antissrf stores every rule IPv4-mapped in one IPv6 BlockList, so allowedAddresses.check(v4, 'ipv4') really does match, making the new assertion form valid; 1.0.0 was published 2026-05-29, so the release-age install policy needs no minimumReleaseAgeExclude entry; no stale references to the three removed exports remain; and all 8 catalog models.url constants are already https://, so the plaintext tightening cannot break a catalog or gateway submit path.

ℹ️ The plaintext tightening has no counterpart at the schema or form layer

allowPlainTextHttp: false is the right default, but nothing upstream tells a user their endpoint will be refused. AccountEndpointSchema.baseUrl is z.url() with no scheme constraint, and add-flow.tsx's CustomDraftSchema uses a bare z.string(), so an http:// custom endpoint stays savable and the refusal only surfaces when the user clicks fetch. That is a product decision rather than a bug — worth making deliberately rather than inheriting from a library default.

Technical details
# Plaintext HTTP endpoints stay configurable but can no longer be probed

## Affected sites
- `packages/foundation/schema/src/model/account.ts:38``baseUrl: z.url()` accepts `http://`.
- `apps/.../add-flow.tsx` `CustomDraftSchema``baseUrl` is `z.string()`, weaker still. The
  `https://…` placeholder is cosmetic.
- `packages/host/engine/src/agent/model-probe.ts:47` — the refusal string the user eventually sees,
  wrapped by `request-handler.ts` into `Model detection failed: …`.

## Required outcome
Decide, and make the codebase say which it is:
- If plaintext endpoints are unsupported, reject the scheme where the URL is entered so the user
  learns at type time rather than at fetch time.
- If a self-hosted plaintext endpoint is a case worth supporting, that needs an explicit,
  per-account opt-in rather than a global policy flag.

## Open questions for the human
- Are there existing accounts in the wild with `http://` base URLs? For custom accounts the probe is
  not in the submit path, so those users keep a working account and lose only model detection —
  which may be an acceptable silent degradation, or may warrant a migration notice.

ℹ️ Nitpicks

  • Two ranges the old hand-rolled table denied are absent from recommendedLatest: ::/96 (IPv4-compatible IPv6) and ::ffff:0:0:0/96 (SIIT). I confirmed PROBE_POLICY now permits ::7f00:1, but connect() to it is ENETUNREACH on Linux, so I could not demonstrate any impact — flagging only because the PR body enumerates what the new table adds without noting what it drops.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

const port = (server.address() as AddressInfo).port;
try {
await expect(
requestPublicModelList(new URL(`http://127.0.0.1:${port}/models`), {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test never reaches the address gate. AntiSSRFHttpAgent.addRequest runs the protocol check first, so an http:// URL is refused with Request headers or protocol disallowed by policy before createConnection is ever called — received === 0 and REFUSAL_PATTERN both hold purely because of allowPlainTextHttp. I proved it: adding 127.0.0.0/8, 169.254.0.0/16, and 192.168.0.0/16 to addAllowedAddresses leaves all 15 tests green. Switching to https:// fixes it — verified failing under that neutering and passing against the real policy.

Technical details
# The address deny-list this PR introduces has no behavioral test

## Affected sites
- `packages/host/engine/src/__tests__/model-probe.test.ts:128-148` — `it('never connects to a
  loopback endpoint')`. `addRequest``_isHttpRequestAllowed` returns false on `http:` +
  `allowPlainTextHttp === false`, emits `AntiSSRFError` on `process.nextTick`, and returns without
  calling `super.addRequest`. `createConnection` and the policy `lookup` never run.
- `packages/host/engine/src/__tests__/model-probe.test.ts:150-161` — asserts `PROBE_POLICY`'s
  `allowedAddresses` / `deniedAddresses` `BlockList`s. A config predicate, not the agent's decision.
- `packages/host/engine/src/__tests__/model-probe.test.ts:163-165` — asserts the
  `allowPlainTextHttp` field, again config not behavior.
- Every remaining transport test passes `unguarded()`, so the guarded agents are exercised by
  nothing.

Net effect: the diff swaps out the entire enforcement mechanism and no test drives it.

## Required outcome
A test that fails if the address policy stops refusing a denied address, covering both gates:
the IP-literal gate in `createConnection` and the policy `lookup` (the gate the doc comment's
"a rebind between check and connect has no gap to land in" claim rests on).

## Suggested approach
The one-character version of this — `http://``https://` on this line — already covers the
IP-literal gate. I ran it: it fails with `write EPROTO … wrong version number` once loopback is
allow-listed (the request connects), and passes against the real policy with `received === 0`.
Adding a `https://localhost:${port}` case covers the DNS gate, since `_lookupAll` refuses when
*any* returned address is denied.

Worth pinning separately: the fake-IP fix depends on antissrf evaluating the allow list *before*
the deny list. That happens in `_isNetworkConnectionAllowed`, which is marked `@internal`, and the
dependency range is `^1.0.0`. If a minor release flipped to deny-first, `198.18.x` would be refused
again and every current test would stay green. A request to an allow-listed-but-unreachable literal
(e.g. `https://198.18.16.15:1/models`) asserting the rejection does *not* match `/public HTTPS/`
would catch that.
Suggested change
requestPublicModelList(new URL(`http://127.0.0.1:${port}/models`), {}),
requestPublicModelList(new URL(`https://127.0.0.1:${port}/models`), {}),

/** Both refusal kinds the policy raises — a denied address, and plaintext HTTP carrying a secret. */
const POLICY_REFUSAL = 'Model detection only reaches public HTTPS endpoints';

export const PROBE_POLICY = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExternalOnlyLatest also sets addXFFHeader = true, so every probe now sends X-Forwarded-For: true to the user's endpoint — I confirmed it on the wire. That value isn't a valid XFF (the header takes an IP list), and it buys nothing here: antissrf adds it to defeat IMDS, but 169.254.169.254 and 168.63.129.16 are already denied by address and none of the re-permitted ranges host a metadata service. Worth setting PROBE_POLICY.addXFFHeader = false unless it's wanted.

Technical details
# `ExternalOnlyLatest` injects an invalid `X-Forwarded-For` into every probe

## Affected sites
- `packages/host/engine/src/agent/model-probe.ts:49` — the `PolicyConfigOptions.ExternalOnlyLatest`
  constructor arm runs `this._addXFFHeader = true` (alongside
  `addDeniedAddresses(IPAddressRanges.recommendedLatest)`).
- Enforced in `AntiSSRFHttpAgent.addRequest``_isHttpRequestAllowed`, which calls
  `req.setHeader("X-Forwarded-For", "true")` when the header is absent.

Observed server-side for a probe request:
`{"accept":"application/json","x-api-key":"…","host":"…","x-forwarded-for":"true","connection":"close"}`

This reaches every configured model endpoint — vendor APIs, third-party gateways, self-hosted
relays. Note the diff deliberately drops the manual `host` header override while silently gaining
this one.

## Required outcome
The outgoing header set is a deliberate choice, not a side effect of the preset. Either suppress it
(`addXFFHeader` is a public setter, so one line after construction) or keep it with a comment saying
why the probe wants to be seen as proxied.

## Open questions for the human
- Any endpoint doing client-IP-based rate limiting, geo-routing, or strict header validation could
  read or reject a malformed XFF. `gateway.linkcode.ai` tolerates it per the PR's verification
  table, but that's one endpoint out of the open set a user can configure.

@AprilNEA
AprilNEA merged commit 99e76b8 into master Aug 21, 2026
12 checks passed
@AprilNEA
AprilNEA deleted the fix/model-probe-fake-ip branch August 21, 2026 18:03
@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

CORE-153

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants