Repository navigation
fix(security): request-scoped transport pinning and managed-write symlink refusal #5264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06334f7
42f0983
de5d554
c3b3e7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| # Lane B — request-scoped transport and managed-write safety | ||
|
|
||
| Status: PR #5264 open against `dev` at exact head `235525b52a`, hosted CI | ||
| in flight. One branch, two ordered commits plus this progress record, per the | ||
| batch topology. | ||
|
|
||
| ## Scope | ||
|
|
||
| - #5087 — DNS pinning and transport selection now follow whether a proxy | ||
| actually applies to the request, not whether one is configured. | ||
| - #5241 — a managed configuration write never follows a terminal symlink to | ||
| another file, including apply/refresh/disable/restore and their races. | ||
|
|
||
| Both items carry the existing contributor pull requests by luvs01 with a | ||
| `Co-authored-by` trailer on each branch commit. The original pull requests | ||
| stay open for the coordinator. | ||
|
|
||
| ## Branch | ||
|
|
||
| `codex/260920-lane-b-transport-write-safety`. Commits in order: | ||
|
|
||
| 1. `fix(transport): decide DNS pinning by whether the proxy applies to the request` | ||
| 2. `fix(integrations): reject symlinked managed write targets` | ||
|
|
||
| ## Review findings on current dev (beyond the carried diffs) | ||
|
|
||
| - #5087's carried model counted a non-SOCKS `ALL_PROXY` for `http:` targets on | ||
| POSIX only. The repository's own provider-outbound e2e drives that exact | ||
| request through the proxy and runs green on the Windows shard too, so the | ||
| platform gate was dropped: `ALL_PROXY` counts for `http:` on every CI | ||
| platform. A present-but-unusable scheme-matched variable now fails closed | ||
| instead of falling through to `ALL_PROXY`. | ||
| - The Mihomo IPv6 fake-IP gate keeps its stricter documented condition | ||
| (scheme-matched variable or SOCKS5 `ALL_PROXY`, non-SOCKS `ALL_PROXY` | ||
| never counts) via a dedicated `schemeMatchedProxyFor`, so the documented | ||
| and tested #3462 behaviour is byte-identical. Moving it to the new snapshot | ||
| would have contradicted the provider docs in every shipped locale. | ||
| - The carried #5087 diff had no regression for the DNS-failure degradation | ||
| branch. Added both directions: a mismatched proxy surfaces the DNS error | ||
| instead of degrading to an unpinned fetch; a scheme-matched proxy keeps the | ||
| degradation. | ||
| - #5241's carried diff re-exported the new primitive from `src/config.ts`, | ||
| which sits exactly at its file-size ratchet cap. The re-export was dropped; | ||
| the only consumer imports the leaf directly. | ||
| - #5241's carried tests covered apply (at rest and swap-during-write) and the | ||
| Cline pair boundary. Added disable and restore refusing a symlinked target | ||
| with the linked file byte-identical. Refresh shares the apply observation | ||
| and write path, so it inherits the same refusals. | ||
|
|
||
| ## Decision-function reach (#5087) | ||
|
|
||
| Every `providerOutboundGet/Post` caller — provider discovery, the | ||
| model-catalog gather modules, quota probes, ollama show, and the management | ||
| model-refresh routes — funnels through the single changed decision. The main | ||
| inference dispatch (`providerFetch`) and OAuth token exchange | ||
| (`src/oauth/*`, bare global fetch) do not use the DNS-pinned transport today | ||
| and are unchanged. | ||
|
|
||
| ## Verification | ||
|
|
||
| - Local suites, focused tests, typecheck, builds and live runs: NOT RUN (lane | ||
| rule). Verification is static source review plus exact-head hosted CI. | ||
|
Comment on lines
+61
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 23129 Run the required changed-file checks before merge. This cohort changes request routing across multiple 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| - Union-defect sweep: no capped file touched (`src/config.ts` left | ||
| byte-identical), no new test files (layout inventories unchanged), nothing | ||
| exhaustive over a union restated (locale catalogs, rosters and generated | ||
| counts untouched). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,7 @@ | |
| import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; | ||
| import type { ConfigFormat } from "../clients/config-export"; | ||
| import { MAX_JSON_NESTING } from "./serialize"; | ||
| import { atomicWriteFile } from "../config"; | ||
| import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write"; | ||
| import type { JournalEntry } from "./journal"; | ||
| import type { OwnershipRecord } from "./ownership"; | ||
| import type { IntegrationClientId } from "./registry"; | ||
|
|
@@ -192,6 +192,15 @@ export type ReadResult = | |
|
|
||
| export type StatKind = "file" | "dir" | "other" | "missing" | "failed"; | ||
|
|
||
| function lstatKind(path: string): StatKind { | ||
| try { | ||
| const stats = lstatSync(path); | ||
| return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other"; | ||
| } catch (error) { | ||
| return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed"; | ||
| } | ||
| } | ||
|
|
||
| export interface IntegrationIO { | ||
| /** | ||
| * ONLY a missing file yields `missing`. Every other failure (EACCES, EPERM, | ||
|
|
@@ -234,6 +243,18 @@ export type TargetState = | |
| * an unreadable config gets clobbered. | ||
| */ | ||
| export function loadTarget(io: IntegrationIO, configPath: string): TargetState { | ||
| // Managed client paths are a lower-trust boundary. Never inspect through a | ||
| // final symlink that can be retargeted between this read and the eventual | ||
| // write: when a no-follow probe exists, the named directory entry itself must | ||
| // be a regular file or absent. `missing` stays legal so a virtual pair probe | ||
| // (such as Cline's pair-aware statKind) can still report one absent member. | ||
| const named = io.lstatKind?.(configPath); | ||
| // A failed probe is uncertainty, not evidence about the entry's shape: it | ||
| // keeps the read-failed classification the follow-probe would have produced. | ||
| if (named === "failed") return { ok: false, why: "read-failed" }; | ||
| if (named !== undefined && named !== "file" && named !== "missing") { | ||
| return { ok: false, why: "not-regular-file" }; | ||
|
Comment on lines
+251
to
+256
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: sed -n '185,325p' src/integrations/config-io.ts
rg -n 'loadTarget\(|lstatKind|readText|statKind' src/integrations src/clients/aside-profiles.ts
sed -n '1,180p' src/integrations/cline-io.tsRepository: lidge-jun/opencodex Length of output: 17180 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- IntegrationIO implementations and loadTarget callers ---'
rg -n -g '*.ts' 'loadTarget\(|:\s*IntegrationIO|IntegrationIO\b|createClineIO\(' src tests | head -240
printf '%s\n' '--- writer loadTarget paths ---'
sed -n '340,420p' src/integrations/writer.ts
sed -n '510,565p' src/integrations/writer.ts
sed -n '570,625p' src/integrations/writer.ts
printf '%s\n' '--- state and aside-profile loadTarget paths ---'
sed -n '500,555p' src/integrations/state.ts
sed -n '90,130p' src/integrations/aside-profile-journal.ts
sed -n '185,220p' src/integrations/aside-profile-journal.ts
printf '%s\n' '--- mutation-plan loadTarget paths ---'
sed -n '485,555p' src/integrations/mutation-plan.ts
sed -n '760,805p' src/integrations/mutation-plan.tsRepository: lidge-jun/opencodex Length of output: 28819 🏁 Script executed: sed -n '560,625p' src/integrations/writer.ts; sed -n '520,550p' src/integrations/state.ts; sed -n '95,120p' src/integrations/aside-profile-journal.ts; sed -n '195,215p' src/integrations/aside-profile-journal.ts; sed -n '525,548p' src/integrations/mutation-plan.ts; sed -n '775,795p' src/integrations/mutation-plan.ts; rg -n -g '*.ts' 'createClineIO|fileIO\(|loadTarget\(' src | head -120Repository: lidge-jun/opencodex Length of output: 11466 🏁 Script executed: #!/bin/bash
set -eu
sed -n '600,705p' src/server/management/integration-routes.ts
sed -n '80,140p' src/integrations/writer.ts
sed -n '625,700p' src/integrations/writer.tsRepository: lidge-jun/opencodex Length of output: 10499 🏁 Script executed: sed -n '600,705p' src/server/management/integration-routes.ts
sed -n '80,140p' src/integrations/writer.ts
sed -n '625,700p' src/integrations/writer.tsRepository: lidge-jun/opencodex Length of output: 10499 Information Disclosure Reachability: External Prevent symlink TOCTOU reads in The writer's later compare-before-commit check also follows the pathname, so it does not validate the original inode. The no-follow write protection prevents redirecting the write, but it does not prevent the outside file from being parsed, snapshotted, or used to build the next configuration. Cline has the same race. Its Add a descriptor-backed no-follow read capability. Open each final entry once with no-follow semantics, verify the descriptor refers to a regular file, and read from that descriptor. Route Cline's pair reads through the same capability. 🤖 Prompt for AI Agents |
||
| } | ||
| const kind = io.statKind(configPath); | ||
| if (kind === "missing") return { ok: true, before: null }; | ||
| if (kind === "failed") return { ok: false, why: "read-failed" }; | ||
|
|
@@ -252,14 +273,7 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState { | |
| */ | ||
| export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "dropRecord"> { | ||
| return { | ||
| lstatKind: path => { | ||
| try { | ||
| const stats = lstatSync(path); | ||
| return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other"; | ||
| } catch (error) { | ||
| return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed"; | ||
| } | ||
| }, | ||
| lstatKind, | ||
| readText: path => { | ||
| try { | ||
| return { kind: "text", text: readFileSync(path, "utf8") }; | ||
|
|
@@ -277,8 +291,28 @@ export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "d | |
| } | ||
| }, | ||
| writeText: (path, text) => { | ||
| const kind = lstatKind(path); | ||
| if (kind !== "file" && kind !== "missing") { | ||
| throw new Error(`refusing unsafe integration write target: ${path}`); | ||
| } | ||
| assertIntegrationWriteOwnership(path); | ||
| atomicWriteFile(path, text); | ||
| atomicWriteFileNoFollow(path, text, undefined, { | ||
| // The pre-check above rejects a symlink already in place; this one runs | ||
| // inside the atomic write immediately before the rename, so a link | ||
| // exchanged after validation is refused rather than followed. Even a | ||
| // swap past this point can only replace the named entry, never redirect | ||
| // the write through it. | ||
| validateBeforeRename: target => { | ||
| try { | ||
| if (lstatSync(target).isSymbolicLink()) { | ||
| throw new Error(`refusing to replace symbolic-link integration target: ${target}`); | ||
| } | ||
| } catch (error) { | ||
| if (isMissingPathError(error)) return; | ||
| throw error; | ||
| } | ||
| }, | ||
| }); | ||
| }, | ||
| removeFile: path => rmSync(path, { force: true }), | ||
| mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ import { | |
| resolvePublicAddresses, | ||
| } from "./destination-policy"; | ||
| import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; | ||
| import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; | ||
| import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env"; | ||
| import { publicProviderBaseUrl } from "./provider-url"; | ||
|
|
||
| type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">; | ||
|
|
@@ -186,13 +186,23 @@ async function providerOutboundRequest( | |
| return provider.fetch(url, { ...init, method, redirect: "manual" }); | ||
| } | ||
| const parsed = postUrl ?? new URL(url); | ||
| const proxyConfigured = outboundProxyConfigured(); | ||
| // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport | ||
| // below reason about the same value. `null` here means "no proxy fetch would actually use", | ||
| // even if some other proxy variable is set. | ||
| // Snapshot the proxy fetch would actually use once, before the DNS await, so admission | ||
| // and transport below reason about the same value. `null` here means "no proxy fetch | ||
| // would actually use", even if some other proxy variable is set. | ||
| const effectiveProxy = effectiveProxyFor(parsed); | ||
| // The request leaves the DNS-pinned transport only when a proxy will actually carry it: | ||
| // a proxy variable fetch would use for this URL that NO_PROXY does not exempt. | ||
| // A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY | ||
| // this target's scheme cannot use must not downgrade pinning or admit | ||
| // proxy-only DNS answers. | ||
| const proxyApplies = effectiveProxy !== null && !noProxyMatches(parsed); | ||
| const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); | ||
| const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed)) | ||
| // The IPv6 fake-IP gate keeps its stricter documented condition — a | ||
| // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS | ||
| // ALL_PROXY — even when proxyApplies admits one for the transport | ||
| // decision, because admission binds the fetch to this value explicitly. | ||
| const bindingProxy = schemeMatchedProxyFor(parsed); | ||
| const allowMihomoIpv6FakeIp = (bindingProxy !== null && !noProxyMatches(parsed)) | ||
| || transparentFakeIpException(url, parsed, isCanonicalUrl, name); | ||
| const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; | ||
| const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; | ||
|
|
@@ -216,7 +226,7 @@ async function providerOutboundRequest( | |
| // proof is on the final request URL — not the provider name — because an | ||
| // OAuth/forward name matches any baseUrl by design while the bearer is | ||
| // pinned to the registry destination independently. | ||
| allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed)) | ||
| allowBenchmarkAddresses: proxyApplies | ||
| || transparentFakeIpException(url, parsed, isCanonicalUrl, name), | ||
| // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound | ||
| // to a scheme-matched proxy (#3462) or under the TUN transparency exception for a | ||
|
|
@@ -229,21 +239,21 @@ async function providerOutboundRequest( | |
| if (!dnsResolutionFailed) { | ||
| throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked"); | ||
| } | ||
| if (!proxyConfigured) throw error; | ||
| if (!proxyApplies) throw error; | ||
| warnProxyBoundaryOnce(); | ||
| warnProxyDnsDegradationOnce(); | ||
| return configuredOutboundFetch(url, { ...init, method, redirect: "manual" }); | ||
| } | ||
| // A canonical TUN exception with no scheme-matched proxy must retain the | ||
| // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. | ||
| if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) { | ||
| if (proxyApplies && !resolved.privateNetwork) { | ||
| warnProxyBoundaryOnce(); | ||
| // When the Mihomo exception could have admitted an answer, pin the transport to the | ||
| // proxy the admission assumed instead of letting fetch re-infer it from the environment. | ||
| const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined; | ||
| const proxy = (allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined; | ||
| return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); | ||
|
Comment on lines
198
to
254
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '80,175p' src/lib/proxy-env.ts
sed -n '175,275p' src/lib/provider-outbound.ts
rg -n 'configuredOutboundFetch|pinnedGet|schemeMatchedProxyFor|socks5' src/lib tests/providers/provider-outbound.test.tsRepository: lidge-jun/opencodex Length of output: 14310 🏁 Script executed: #!/bin/bash
sed -n '175,235p' src/lib/proxy-env.ts
sed -n '275,355p' src/lib/provider-outbound.ts
sed -n '700,825p' tests/providers/provider-outbound.test.ts
sed -n '880,980p' tests/providers/provider-outbound.test.tsRepository: lidge-jun/opencodex Length of output: 12879 🏁 Script executed: #!/bin/bash
rg -n -C 8 'allowMihomoIpv6FakeIp|Mihomo|fdfe:dcba:9876|fake.?IP|198\\.18' src tests/providers/provider-outbound.test.tsRepository: lidge-jun/opencodex Length of output: 50375 Route scheme-specific SOCKS proxies consistently. A DNS failure does not reach 🤖 Prompt for AI Agents |
||
| } | ||
| if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { | ||
| if (proxyApplies && resolved.privateNetwork) { | ||
| const hostname = normalizeProxyHostname(parsed.hostname); | ||
| throw new Error( | ||
| `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This newly tracked
_plandocument records active security review findings, transport-boundary reasoning, and pre-merge verification details while the fix is still awaiting hosted CI. Repository policy explicitly requires unreleased security findings and pre-disclosure patch reasoning to remain in scratch space rather thandevlog/; remove this file from the commit and retain the notes under.tmp/until a publishable post-fix outcome exists.AGENTS.md reference: AGENTS.md:L124-L134
Useful? React with 👍 / 👎.