Skip to content

feat(egress): optional per-host HTTPS passthrough route allowlist (OPT-1171) - #16

Merged
Optale-ops merged 1 commit into
mainfrom
security/opt-1171-path-allowlist
Oct 4, 2026
Merged

Optale-ops merged 1 commit into
mainfrom
security/opt-1171-path-allowlist

Conversation

@Optale-ops

Copy link
Copy Markdown
Owner

OPT-1171 (Console security audit), engine half: "Read Only code can reach any Console page; limit it to the agent tool routes".

Problem

The signed network_policy is host-only. validatePolicyUrl looks a host up by hostname, and the passthrough copies the code's method, headers and body. So a Read Only run, whose snapshot names only the Console host, can still send any method to any Console route, for example POST /api/auth/requestPasswordReset.

Change

  • ExternalFetchHostPolicy / snapshot: optional httpsPassthroughRoutes: [{ method, path }] (1-64 entries; methods DELETE GET HEAD PATCH POST PUT). Allowed only on a host with httpsPassthrough. A path must already be canonical: it starts with /, contains no ? # % \ or whitespace, and equals its own URL pathname, so no dot segments and no second spelling. Duplicates and extra keys are rejected. Routes are stored sorted by path, then method.
  • validateHttpsPassthroughUrl(raw, policy, method): when the host lists routes, the uppercased method and the parsed url.pathname must match one exactly, and the URL must carry no query. Otherwise HOST_NOT_ALLOWED, thrown before any DNS lookup or connection (openHttpsPassthrough validates first and now passes args.method). The gateway already rejects passthrough redirects, so the check on the requested URL is the whole check.
  • serializeExternalFetchPolicy includes routes, so the signed digest covers them.
  • intersectExternalFetchPolicies: if neither side lists routes, there's no restriction (today's behaviour). If one side lists them, that side applies. If both list them, every signed route must be in the deployment's, or HOST_NOT_ALLOWED.

Encoded, case-changed or trailing-slash variants of a listed path are refused: they fail the exact match. Express would route some of them to the same handler, so the direction is safe.

Backward compatibility (requested by Foundation)

The field is optional and absent from today's deployment file and from every snapshot the Console signs today. Proof: old-new-equivalence.ts imports the aa3cb4b module and this branch's module side by side. It uses a deployment policy shaped like production's external-fetch-policy.v2.json (R2 PDF host, four Console passthrough hosts at 300 s / 8 fetches / 2 MiB, three package registries) and today's two Console snapshot shapes (Read Only: Console passthrough only; Auto: Console, package registries, R2). It digests each snapshot the way the Console does (sha256(JSON.stringify(<sorted keys>)), canonicalizeCodeApiNetworkPolicy):

aa3cb4b this branch
Deployment serialization digest cbeM2O…25s identical bytes, same digest
Read Only: Console digest = engine digest ojOvrO…FIZQ same; effective policy byte-identical
Auto: Console digest = engine digest NfdaNM…BtE same; effective policy byte-identical
Passthrough POST /api/auth/requestPasswordReset allowed allowed (unchanged until the Console signs routes)
Passthrough POST /api/optale/mcp allowed allowed
Unlisted host HOST_NOT_ALLOWED HOST_NOT_ALLOWED

The same compatibility is pinned in external-fetch-policy.test.ts ("compatibility with the policies the Console signs today").

Tests

  • external-fetch-policy.test.ts: route parsing (canonical order, digest coverage) and ten rejected shapes. Enforcement: the listed route is allowed; wrong method, query, trailing slash, case change, percent-encoding, another route, and the other listed route with the wrong method are all refused. Intersection: subset allowed, outside route refused, deployment routes applied to an unrouted signature.
  • external-fetch-boundary.fixture.ts (real DNS/TLS in a private netns): with routes, POST /passthrough reaches the origin; GET /passthrough, POST /success and POST /passthrough?x=1 are refused and the origin sees exactly one request.
  • Full service suite: 720/720 locally (bun 1.4.1). Negative control: with route enforcement disabled, three tests fail, including the real-TLS boundary test.

Rollout (agreed with Foundation)

  1. Merge and deploy to AX41 (production and staging share it). Today's policies carry no routes, so egress is unchanged (proof above).
  2. A later Console release signs httpsPassthroughRoutes for Read Only runs: GET /api/optale/composio/catalog/OPTALE_CORE/actions and POST /api/optale/mcp. Those are the only two sandbox→Console requests (ops/optale-agent/transport.cjs, confirmed by Agent/MCP). It must not ship before step 1: an aa3cb4b engine rejects the unknown key and would refuse every Read Only grant. The Console must emit routes sorted by path, then method, or its digest won't match.

…T-1171)

The signed network policy is host-only: a host with httpsPassthrough accepts any
method and path, so a Read Only code run can send any request to any Console
route (for example POST /api/auth/requestPasswordReset).

A host may now list httpsPassthroughRoutes: exact {method, path} pairs. When
listed, the gateway's passthrough validation (openHttpsPassthrough, with the
envelope's method) allows only a listed method on a listed, already-normalized
path with no query, and refuses anything else as HOST_NOT_ALLOWED before
connecting. Paths must be canonical (no query, fragment, percent-encoding,
backslash or dot segments). Routes are part of the snapshot, so the signed digest
covers them, and the deployment policy caps them: listed on both sides, every
signed route must be in the deployment's; listed on one side, that side applies.

The field is optional and absent from today's policies, which serialize, digest
and intersect exactly as before.
@Optale-ops
Optale-ops merged commit 0a2d3d2 into main Oct 4, 2026
5 checks passed
Optale-ops added a commit that referenced this pull request Oct 4, 2026
…th (#17)

* fix(egress): refused requests log a hash of the requested host and path

Egress audit lines carried destinationHost and pathHash only after a request
passed policy validation, so a HOST_NOT_ALLOWED refusal could not be traced
to a destination (seen on AX41 after the #16 deploy). The gateway now records
destinationHostHash (hashLabel of the lowercased host) plus pathHash and
queryPresent from the requested URL right after the grant is read, for HTTPS
passthrough, package transport and typed fetch. The host itself is not logged
for refusals, since sandbox code chooses it freely; an operator compares the
hash with hashLabel of a candidate host.

* fix(egress): host hash only on records without a validated destination; tests select by route and outcome (#17 review)
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.

1 participant