Skip to content

fix(egress): let self-hosted deployments reach named private destinations - #7229

Open
waleedlatif1 wants to merge 20 commits into
stagingfrom
investigate/http-docker-7200
Open

fix(egress): let self-hosted deployments reach named private destinations#7229
waleedlatif1 wants to merge 20 commits into
stagingfrom
investigate/http-docker-7200

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7200.

The bug

The issue is filed as a Docker networking problem. The screenshot shows the real failure — url must use https:// protocol on http://host.docker.internal:7274/.... Sim refused the URL before opening a socket, and the message named the wrong cause: switching to https would have failed too, on the private-address check one step later.

Plain http was permitted only for a literal localhost or loopback IP. In a container loopback is the one address guaranteed useless — it is the container itself — so the carve-out existed exactly where it could not help. Behind it, the DNS filter dropped every private address with no operator opt-out, so self-hosted Sim could not reach a LAN vLLM, a Jupyter server, GitHub Enterprise, or a sibling container by service name either.

Why it needed more than a patch

There was no policy object — just a hardcoded default plus four incompatible escape hatches grown one per use case:

Hatch Relaxed Shape
allowHttp protocol only option flag
isLocalhost && !isHosted reachability hardcoded
allowRedirectToIp one IP hand-threaded param
ALLOW_PRIVATE_DATABASE_HOSTS reachability env var, one validator

They disagreed. allowHttp: true relaxed the protocol gate while the address gate refused the host anyway — so vLLM and Jupyter on a LAN were broken despite passing it, while 1Password and ClickHouse worked because they route through different validators with their own rules.

The change

@sim/security/egress — a pure policy value and two decision functions. No DNS, no env, no deployment-posture global. That is what lets both postures be tested in one file with no module mocking; the hosted branch of the guard had no coverage at all before, because isHosted is a module const the suite pins to false.

lib/core/security/egress — profiles keyed on where the URL came from, because provenance is what determines trust:

Profile Origin Honors allowlist
configuredEndpoint typed during setup — vLLM, Jupyter, GHE, Grafana, MCP, connector hosts yes
requestTarget per run — HTTP block, A2A, RSS, Function fetch yes
databaseHost datastore hosts yes, no loopback carve-out
contentFetch harvested from content or model output never
proxy the egress proxy itself never, public only

contentFetch earns the taxonomy: it is the class where SSRF is actually exploited, so it stays locked even on a deployment that allowlisted its whole internal range. databaseHost gets no loopback carve-out because loopback is where Sim's own database listens.

The profile is required at all ~190 call sites and travels on SecureFetchOptions, so every redirect hop is judged by the policy the request started under. allowHttp and allowRedirectToIp are deleted.

Configuration

EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local
EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8

Naming a destination permits plain http to it and lifts the blocked-port list for it — one decision about one host, not three switches. Cloud metadata endpoints stay blocked however broad the allowlist is. Both variables are ignored on the hosted platform.

Allowlist only: no configurable blocklist. n8n exposes one and it is a footgun with no use case an allowlist does not serve better.

Compatibility

ALLOW_PRIVATE_DATABASE_HOSTS keeps working as a deprecated alias that expands to the private space it always stood for, so existing deployments are unaffected. It is dropped from the docs in favour of naming specific destinations, and logs a deprecation warning at startup.

One behavior change worth review: cross-origin redirects now drop Authorization/Cookie by default. Previously the node transport dropped them only when a redirectPolicy was supplied, so the many callers that passed none forwarded credentials to whatever host a redirect named. Keeping them is now an explicit sendCredentialsOnCrossOriginRedirect: true. Two reviewers flagged this at ~30 separate call sites, which is what identified the default as the bug rather than those sites.

Cleanup

Net −733 lines of production code. Removes nine dead exports, three copies of "validate then host-suffix allowlist", a duplicate of validateJiraCloudId, and the second validateUrlWithDNS in the API block handler — it discarded its pinned address and validated a pre-templating string that was not the URL dialled.

Adds check:egress-boundary, which fails CI if a raw HTTP transport appears outside the guard. It auto-enrols into check:audits.

Compose files gain host.docker.internal:host-gateway — without it the hostname does not resolve on Linux at all, a third distinct error for one cause.

Verification

  • bunx turbo run type-check — 26/26 clean
  • bun run check:audits — 40/40 pass
  • Full apps/sim suite — 36,968 passed, 0 failed
  • @sim/security — 190 passed, including 65 new egress tests covering both postures, metadata non-overridability, wildcard scoping, and fail-closed on unparseable input

Review round

The header fix above left a second leak on the same path: a 307/308 preserves the request body verbatim, so a cross-origin redirect on a credential-bearing POST still forwarded the payload — Agiloft's EWLogin form carries $password that way, and 30 call sites send a body with redirects enabled. followRedirectsGuarded has always refused this on the undici path; the node path now matches, with allowCrossOriginBody on HttpRedirectPolicy as the opt-in. Nothing opts in today.

Also fixed after review:

  • ::a9fe:a9fe — the form the URL parser normalizes ::169.254.169.254 to — bypassed the metadata comparison for an allowlisted destination. Addresses are folded to a canonical IPv4 first.
  • The loopback carve-out vouched on hostname alone, so a resolver answering localhost with a routable address kept the exemption. The address must land on loopback too.
  • ALLOW_PRIVATE_DATABASE_HOSTS fed the shared allowlist, which requestTarget also honors — an upgrading deployment would have handed workflow authors a route into its network. Now scoped to databaseHost, and it grants "any private address" rather than a hand-written range list that omitted CGNAT (where Tailscale lives).
  • allowHttp had permitted plain HTTP to any host; folding those four sites into configuredEndpoint made it conditional, breaking http:// vLLM and Jupyter endpoints on Sim Cloud. They use a selfHostedService profile instead.
  • Textract fetches both caller-supplied document URLs and presigned URLs against Sim's own storage, which on self-hosted MinIO can be loopback. Provenance is threaded rather than flattened.
  • Windchill's ReplicaUrl and the link-preview URL are response- and content-derived, so both moved to contentFetch.

Parallel implementations retired

All three duplicates are gone, so one policy governs every outbound request:

  • allowRedirectToIp — a hand-threaded carve-out permitting exactly one pinned address across a redirect. The undici path carries the request's profile instead, so the policy can express "this range is permitted" where the carve-out could only say "this one address".
  • lib/internal/onepassword — resolved and classified addresses itself, permitting every private range on self-hosted with no way to configure it.
  • lib/mcp/domain-check — same, plus two paths that returned null meaning run with no guard at all, one of them whenever ALLOWED_MCP_DOMAINS was set. That left an allowlisted domain free to redirect anywhere, cloud metadata included. Domain governance and the address check are separate questions and both apply now.

Both migrated modules use selfHostedService, which gains denyServicePorts: false — an operator-run service binds whatever port it likes, and refusing the eight non-HTTP ports there would have been a silent narrowing.

This also fixed a bug the migration surfaced: allowLoopback was computed from isHosted when the profile table was first imported, so the hosted branch used the wrong policy and no test could exercise it. The posture is part of the config the cache is keyed on now.

Breaking changes

Self-hosted only, and each has the same remedy — name the destination in EGRESS_ALLOWED_HOSTS / EGRESS_ALLOWED_IP_RANGES:

Path Was Now
1Password Connect any private address, no port policy loopback, or allowlisted; the port denylist now applies
MCP server any loopback; any address when ALLOWED_MCP_DOMAINS set loopback by hostname, or allowlisted
MCP via a DNS name pointed at loopback permitted allowlist the hostname

ALLOW_PRIVATE_DATABASE_HOSTS keeps working as a deprecated alias for database hosts, so nothing there changes.

Three more that have no allowlist remedy, because the provenance is content rather than configuration:

Path Was Now
A content fetch to a loopback or private address — an image URL, a file imported by URL, a Slack url_private permitted off-hosted refused; contentFetch never consults the allowlist
An MCP OAuth endpoint the server's metadata names on another origin inherited the server's reach judged as content, so it must be publicly routable
An HTTP block's proxyUrl on a private or loopback address refused unchanged, but now with a message that says the allowlist will not help

And three that are the same for every deployment:

  • Cross-origin redirects drop Authorization, Cookie and proxy-authorization unless sendCredentialsOnCrossOriginRedirect is set; previously any caller that passed no redirect policy forwarded them.
  • A cross-origin redirect that would carry a request body is refused rather than replayed, unless allowCrossOriginBody is set.
  • Redirect hops are judged under the request's own provenance, so a hop that downgrades to plain HTTP or lands on a blocked port is refused — previously only a private IP literal was checked. Only 301, 302, 303, 307 and 308 are followed; 305 (Use Proxy) in particular is not.

One loosening, self-hosted only: naming a destination lifts the blocked-port list for it, so an allowlisted host exposes 22/3306/6379 on that host to every workflow author. The loopback carve-out deliberately does not — http://localhost:5432 stays refused until localhost is named.

@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 28, 2026 20:28
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 29, 2026 6:42am

Request Review

@cubic-dev-ai cubic-dev-ai 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.

33 issues found across 124 files

Confidence score: 1/5

  • Authenticated requests in apps/sim/lib/data-drains/destinations/webhook.ts, apps/sim/lib/internal/slack/operations.ts, and apps/sim/lib/internal/azure-data-explorer/client.ts can forward bearer tokens on cross-origin redirects, exposing credentials to redirect targets — apply a policy that disables cross-origin credential forwarding.
  • apps/sim/lib/internal/agiloft/client.ts can replay the login form, including $password, to a cross-origin 307/308 redirect; its operations and logout requests can also forward Authorization — reject credential-bearing redirects and enforce an appropriate redirect policy.
  • Bearer tokens may leak through cross-origin redirects in apps/sim/lib/internal/extend/client.ts and apps/sim/connectors/sentry/sentry.ts because these calls omit redirectPolicy — add the standard policy that disables cross-origin credentials.
  • API credentials may be sent to redirect destinations from apps/sim/lib/webhooks/providers/emailbison.ts, apps/sim/lib/webhooks/providers/gitlab.ts, and apps/sim/lib/internal/pulse/client.ts — configure each request to prevent cross-origin credential replay.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/data-drains/destinations/webhook.ts">

<violation number="1" location="apps/sim/lib/data-drains/destinations/webhook.ts:168">
P1: When a webhook URL returns a cross-origin redirect, `secureFetchWithPinnedIP` keeps `options.headers` unchanged because these calls omit `redirectPolicy`, sending the bearer `Authorization` header to the redirect target. Add `sendCredentialsOnCrossOriginRedirect: false` to both webhook fetch policies, using legacy mode to preserve webhook replay behavior.

(Based on your team's feedback about Strip credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/clickhouse/client.ts">

<violation number="1" location="apps/sim/lib/internal/clickhouse/client.ts:77">
P1: When `secure` is true on a self-hosted deployment, an HTTPS ClickHouse endpoint can redirect to `http://localhost` and this profile follows it because loopback is vouched. Carry the configured secure requirement into redirect validation instead of selecting a policy that ignores it.</violation>
</file>

<file name="apps/sim/lib/internal/slack/operations.ts">

<violation number="1" location="apps/sim/lib/internal/slack/operations.ts:328">
P1: When Slack returns a cross-origin redirect for `url_private`, this request forwards the Slack bearer token to the redirect host. Pass a redirect policy with `sendCredentialsOnCrossOriginRedirect: false` so the token is removed before following the redirect.</violation>
</file>

<file name="apps/sim/lib/internal/azure-data-explorer/client.ts">

<violation number="1" location="apps/sim/lib/internal/azure-data-explorer/client.ts:87">
P1: When the cluster returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this request omits `redirectPolicy`; add a standard policy that disables cross-origin credentials.

(Based on your team's feedback about strip credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/agiloft/client.ts">

<violation number="1" location="apps/sim/lib/internal/agiloft/client.ts:84">
P1: When Agiloft returns a cross-origin 307/308 from `EWLogin`, `secureFetchWithPinnedIP` replays the form body, including `$password`, to the redirect target. Reject redirects for this credential-bearing login or enforce a cross-origin body prohibition before following them.</violation>

<violation number="2" location="apps/sim/lib/internal/agiloft/client.ts:84">
P1: When an Agiloft operation or logout receives a cross-origin redirect, `secureFetchWithPinnedIP` forwards `Authorization` because these options omit `redirectPolicy`. Set `redirectPolicy: { mode: 'legacy', sendCredentialsOnCrossOriginRedirect: false }` on every authenticated Agiloft pinned request.</violation>
</file>

<file name="apps/sim/lib/internal/extend/client.ts">

<violation number="1" location="apps/sim/lib/internal/extend/client.ts:37">
P1: When the Extend API returns a cross-origin redirect, this call can send its bearer token to the redirect target because no redirect policy disables credential forwarding. Add a standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.</violation>
</file>

<file name="apps/sim/connectors/sentry/sentry.ts">

<violation number="1" location="apps/sim/connectors/sentry/sentry.ts:375">
P1: When a Sentry endpoint returns a cross-origin redirect, these authenticated requests can forward the bearer token because they omit `redirectPolicy`. Add `redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false }` to every Sentry fetch, or make the secure-fetch default enforce this policy.

(Based on your team's feedback about cross-origin redirect credential stripping.)</violation>
</file>

<file name="apps/sim/lib/webhooks/providers/emailbison.ts">

<violation number="1" location="apps/sim/lib/webhooks/providers/emailbison.ts:163">
P1: When an Email Bison endpoint returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the API token because these calls omit `redirectPolicy`. Add `redirectPolicy: { mode: 'legacy', sendCredentialsOnCrossOriginRedirect: false }` to both the create and delete requests so redirects cannot disclose the token.</violation>
</file>

<file name="apps/sim/lib/webhooks/providers/gitlab.ts">

<violation number="1" location="apps/sim/lib/webhooks/providers/gitlab.ts:37">
P1: When GitLab returns a cross-origin redirect, these requests forward `PRIVATE-TOKEN` to the redirect destination because no redirect policy opts out of credential replay. Add `redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false }` to each of the four requests.</violation>
</file>

<file name="apps/sim/lib/internal/pulse/client.ts">

<violation number="1" location="apps/sim/lib/internal/pulse/client.ts:33">
P1: When Pulse returns a cross-origin redirect, this call follows it without a `redirectPolicy`, so `x-api-key` is forwarded to the redirect target. Add a legacy policy that disables cross-origin credentials and lists `x-api-key` as sensitive.

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/onepassword/client.ts">

<violation number="1" location="apps/sim/lib/internal/onepassword/client.ts:382">
P1: When the Connect server returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this call omits `redirectPolicy`, potentially disclosing the Connect API key to the redirect target. Pass a redirect policy with `sendCredentialsOnCrossOriginRedirect: false` while retaining legacy method behavior.

(Based on your team's feedback about stripping credentials on cross-origin redirects.) .</violation>
</file>

<file name="apps/sim/lib/internal/mistral/client.ts">

<violation number="1" location="apps/sim/lib/internal/mistral/client.ts:34">
P1: When Mistral returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this call omits `redirectPolicy`. Add a standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false` before following redirects.</violation>
</file>

<file name="apps/sim/lib/internal/servicenow/client.ts">

<violation number="1" location="apps/sim/lib/internal/servicenow/client.ts:33">
P1: When the ServiceNow endpoint returns a cross-origin redirect, this call forwards its Basic `Authorization` header because `secureFetchWithPinnedIP` preserves replay semantics when `redirectPolicy` is omitted. Supply a standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/webhooks/providers/slack.ts">

<violation number="1" location="apps/sim/lib/webhooks/providers/slack.ts:354">
P1: When a Slack `url_private` response redirects cross-origin, this call forwards the Slack bot token because `secureFetchWithPinnedIP` only strips credentials when `redirectPolicy` is provided. Add a standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`. 

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/zoom/operations.ts">

<violation number="1" location="apps/sim/lib/internal/zoom/operations.ts:77">
P1: When the Zoom API returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this request omits `redirectPolicy`, allowing the redirect target to receive Zoom credentials. Add the standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.\n\n(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>

<violation number="2" location="apps/sim/lib/internal/zoom/operations.ts:115">
P1: When a provider-returned recording URL redirects across origins, `secureFetchWithPinnedIP` forwards the Zoom bearer token because this content-fetch request omits `redirectPolicy`. Add the standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.\n\n(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/typeform/operations.ts">

<violation number="1" location="apps/sim/lib/internal/typeform/operations.ts:53">
P1: When Typeform returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this request has no credential-stripping redirect policy. Add a standard redirect policy with `sendCredentialsOnCrossOriginRedirect: false`. 

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/webhooks/providers/microsoft-teams.ts">

<violation number="1" location="apps/sim/lib/webhooks/providers/microsoft-teams.ts:107">
P1: When a Teams content URL returns a cross-origin redirect, this call forwards the OAuth bearer token to the redirect host. `profile` does not strip credentials; pass a redirect policy with `sendCredentialsOnCrossOriginRedirect: false` while preserving the existing legacy redirect semantics.</violation>
</file>

<file name="apps/sim/lib/internal/reducto/client.ts">

<violation number="1" location="apps/sim/lib/internal/reducto/client.ts:36">
P1: When Reducto responds with a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this request has no `redirectPolicy`. Add a standard policy with `sendCredentialsOnCrossOriginRedirect: false`. 

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/google-slides/operations.ts">

<violation number="1" location="apps/sim/lib/internal/google-slides/operations.ts:59">
P1: When the Google export endpoint returns a cross-origin redirect, this call forwards `Authorization` because it omits `redirectPolicy`. Set `sendCredentialsOnCrossOriginRedirect: false` so the bearer token cannot reach the redirect target.

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts">

<violation number="1" location="apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts:99">
P1: When a fetched URL redirects cross-origin, this call can forward caller-supplied `Authorization` or `Cookie` headers to the redirect target because it omits `redirectPolicy`. Set `sendCredentialsOnCrossOriginRedirect: false` while preserving the existing redirect mode.</violation>
</file>

<file name="apps/sim/lib/internal/sharepoint/client.ts">

<violation number="1" location="apps/sim/lib/internal/sharepoint/client.ts:111">
P1: When a metadata request follows a cross-origin redirect, `secureFetchWithPinnedIP` forwards the Bearer token because this options object omits `redirectPolicy`. Set `sendCredentialsOnCrossOriginRedirect: false` in a redirect policy before sending credentials.

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>

<violation number="2" location="apps/sim/lib/internal/sharepoint/client.ts:168">
P1: When an upload follows a cross-origin redirect, `secureFetchWithValidation` can forward the Bearer token because this options object omits `redirectPolicy`. Add a policy with `sendCredentialsOnCrossOriginRedirect: false` to prevent credential forwarding.

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/github/operations.ts">

<violation number="1" location="apps/sim/lib/internal/github/operations.ts:326">
P1: When a GitHub raw URL redirects cross-origin, this request forwards the GitHub bearer token because `secureFetchWithPinnedIP` strips credentials only when `redirectPolicy` is supplied. Add a legacy redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.</violation>

<violation number="2" location="apps/sim/lib/internal/github/operations.ts:367">
P1: When the GitHub API request redirects cross-origin, this request forwards the API bearer token because no `redirectPolicy` is provided. Add a legacy redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.</violation>
</file>

<file name="apps/sim/lib/internal/onedrive/operations.ts">

<violation number="1" location="apps/sim/lib/internal/onedrive/operations.ts:436">
P1: When Microsoft Graph returns a cross-origin redirect, these bearer-authenticated requests follow it without a credential-stripping policy, so the redirect host receives the OneDrive token. Pass `redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false }` in both `graphRequest` and `fetchGraph`. 

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/zoominfo/client.ts">

<violation number="1" location="apps/sim/lib/internal/zoominfo/client.ts:77">
P1: When either ZoomInfo request follows a cross-origin redirect, the Basic or Bearer credential is forwarded to the redirect host because `redirectPolicy` is omitted. Add `redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false }` to both request option objects.

(Based on your team's feedback about credential stripping on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/microsoft-word/client.ts">

<violation number="1" location="apps/sim/lib/internal/microsoft-word/client.ts:68">
P1: When a Microsoft Graph request returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because `graphFetch` provides no credential-stripping policy. Set `redirectPolicy` here with `sendCredentialsOnCrossOriginRedirect: false` so metadata and upload-session redirects cannot leak credentials.</violation>
</file>

<file name="apps/sim/lib/internal/google-vault/operations.ts">

<violation number="1" location="apps/sim/lib/internal/google-vault/operations.ts:55">
P1: When the GCS endpoint returns a cross-origin redirect, `secureFetchWithPinnedIP` forwards the bearer token because this call omits `redirectPolicy`. Add a legacy redirect policy with `sendCredentialsOnCrossOriginRedirect: false`.\n\n(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/lib/internal/linq/client.ts">

<violation number="1" location="apps/sim/lib/internal/linq/client.ts:95">
P1: When a Linq response includes an auth-bearing required header and the upload URL redirects cross-origin, `secureFetchWithPinnedIP` forwards that header to the redirect target. Set `sendCredentialsOnCrossOriginRedirect: false` to prevent credential leakage.</violation>
</file>

<file name="apps/sim/lib/internal/twilio-voice/operations.ts">

<violation number="1" location="apps/sim/lib/internal/twilio-voice/operations.ts:59">
P1: When Twilio returns a cross-origin redirect, this request forwards the Basic credential to the redirect host because no `redirectPolicy` is configured. Add a standard policy with `sendCredentialsOnCrossOriginRedirect: false`. 

(Based on your team's feedback about stripping credentials on cross-origin redirects.)</violation>
</file>

<file name="apps/sim/connectors/gitlab/gitlab.ts">

<violation number="1" location="apps/sim/connectors/gitlab/gitlab.ts:137">
P1: When GitLab returns a cross-origin redirect, these authenticated requests can forward the `PRIVATE-TOKEN` header to the redirect target. Pass a shared redirect policy with `sendCredentialsOnCrossOriginRedirect: false` and `sensitiveHeaders: ['private-token']` to every GitLab fetch.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/sim/lib/data-drains/destinations/webhook.ts
Comment thread apps/sim/lib/internal/slack/operations.ts
Comment thread apps/sim/lib/internal/azure-data-explorer/client.ts
Comment thread apps/sim/lib/internal/agiloft/client.ts
Comment thread apps/sim/lib/internal/pulse/client.ts
Comment thread apps/sim/lib/internal/clickhouse/client.ts Outdated
Comment thread apps/sim/lib/core/security/egress/validate.ts
Comment thread apps/sim/lib/internal/github/operations.ts
Comment thread apps/sim/lib/internal/github/operations.ts
Comment thread scripts/check-egress-boundary.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces several independent outbound-request exceptions with provenance-based egress profiles and configurable private-network allowlists for self-hosted deployments.

  • Adds centralized egress policy evaluation, DNS/address validation, metadata protection, and guarded redirect handling.
  • Assigns required provenance profiles across outbound integrations and preserves each request’s policy across redirects.
  • Adds self-hosting configuration, documentation, Compose host aliases, boundary audits, and broad policy and transport coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/security/src/egress.ts Introduces the pure egress policy model, allowlist parsing, address canonicalization, metadata protection, and scheme/port decisions.
apps/sim/lib/core/security/egress/profiles.ts Maps URL provenance to deployment-aware policy profiles and scopes legacy database access to database hosts.
apps/sim/lib/core/security/input-validation.server.ts Integrates profile-aware validation with pinned outbound transports and guarded redirect processing.
apps/sim/lib/core/security/http-redirect-policy.ts Defines redirect method, credential, and cross-origin request-body policy.
apps/sim/lib/mcp/pinned-fetch.ts Applies separate configured-server and metadata-derived profiles to MCP and OAuth requests.
scripts/check-egress-boundary.ts Adds an audit preventing unguarded HTTP transports outside approved boundaries.
apps/docs/content/docs/platform/self-hosting/security.mdx Documents provenance profiles, private-network allowlists, metadata restrictions, redirects, and upgrade behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  URL[Outbound URL] --> Origin{URL provenance}
  Origin -->|Configured endpoint| CE[configuredEndpoint]
  Origin -->|Self-hosted service| SH[selfHostedService]
  Origin -->|Per-run request| RT[requestTarget]
  Origin -->|Content or response derived| CF[contentFetch]
  Origin -->|Database/cache/mail host| DB[databaseHost]
  Origin -->|Egress proxy| PX[proxy]
  CE --> Policy[Egress policy evaluation]
  SH --> Policy
  RT --> Policy
  CF --> Policy
  DB --> Policy
  PX --> Policy
  Policy --> DNS[Resolve and classify address]
  DNS --> Metadata{Cloud metadata?}
  Metadata -->|Yes| Block[Block request]
  Metadata -->|No| Allowlist{Profile honors allowlist?}
  Allowlist -->|Yes| Config[Apply self-hosted host/IP allowlist]
  Allowlist -->|No| Public[Require public destination]
  Config --> Pin[Pin approved address]
  Public --> Pin
  Pin --> Fetch[Guarded transport]
  Fetch --> Redirect{Redirect?}
  Redirect -->|Yes| Policy
  Redirect -->|Cross-origin| Sanitize[Drop credentials; reject preserved body by default]
  Redirect -->|No| Response[Return bounded response]
  Sanitize --> Policy
Loading

Reviews (8): Last reviewed commit: "fix(egress): judge an IPv6 address by th..." | Re-trigger Greptile

@cubic-dev-ai cubic-dev-ai 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.

11 issues found across 148 files

Confidence score: 2/5

  • apps/sim/lib/mcp/domain-check.ts grants self-hosted endpoint privileges to URLs from MCP OAuth metadata, allowing a remote MCP server to steer OAuth discovery or token/revocation requests; restrict privileges to configured endpoints.
  • scripts/check-egress-boundary.ts misses dynamic import() and require() calls for undici, allowing raw outbound requests to bypass the new egress boundary; add syntax-aware detection for all runtime module loads.
  • apps/sim/lib/internal/stt/operations.ts rejects authorized internal audioUrl values on self-hosted deployments when they resolve to loopback or private storage endpoints, causing transcription downloads to fail; allow the guarded internal destinations through contentFetch.
  • apps/sim/lib/core/security/input-validation.ts accepts HTTP for allowlisted vendor hosts, enabling an unintended downgrade for ServiceNow, Workday, or Databricks integrations; require https: for these vendor destinations.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="package.json">

<violation number="1" location="package.json:30">
P2: This removes Helm's `--strict` mode, so `lint:all` can pass when the chart emits warnings. Keep strict linting while switching to the new values fixture.</violation>
</file>

<file name="apps/sim/lib/internal/stt/operations.ts">

<violation number="1" location="apps/sim/lib/internal/stt/operations.ts:258">
P2: When `audioUrl` is an authorized internal file URL on a self-hosted deployment, resolving it to the local app or private storage endpoint now makes the download fail because `contentFetch` cannot use loopback, HTTP, or the operator allowlist. Preserve the internal-file provenance with a trusted internal profile for both validation and pinned fetch, while retaining `contentFetch` for direct external URLs.</violation>
</file>

<file name="apps/sim/lib/mcp/domain-check.ts">

<violation number="1" location="apps/sim/lib/mcp/domain-check.ts:14">
P1: URLs obtained from MCP OAuth metadata are not configured endpoints, but this shared profile gives them the self-hosted endpoint privileges. A remote MCP server can therefore steer OAuth discovery or token/revocation requests to an allowlisted private destination; keep `selfHostedService` for the configured MCP URL and use a stricter response-derived URL profile for metadata and redirect hops.</violation>
</file>

<file name="apps/docs/content/docs/platform/self-hosting/security.mdx">

<violation number="1" location="apps/docs/content/docs/platform/self-hosting/security.mdx:152">
P2: This copy-paste example allowlists every Kubernetes service under `*.svc.cluster.local` and the entire `10.0.0.0/8`, contradicting the warning to name specific hosts and narrow ranges. Replace it with a concrete service hostname and narrow IP/CIDR examples so the security guidance does not grant workflows broad cluster or VPC access.</violation>
</file>

<file name="apps/sim/lib/core/security/input-validation.test.ts">

<violation number="1" location="apps/sim/lib/core/security/input-validation.test.ts:2008">
P2: The deleted suite was the only test coverage for `validateSupabaseProjectId`, which is still exported and used in production (apps/sim/lib/internal/supabase/operations.ts and apps/sim/tools/supabase/utils.ts). Unlike the other functions whose tests were removed here, this validator remains live, and its suite specifically covered SSRF vectors (fragment/`@` authority injection, path traversal, URL-encoded and header-injection payloads). Restore a matching test suite for it.</violation>
</file>

<file name="apps/sim/lib/core/security/input-validation.server.ts">

<violation number="1" location="apps/sim/lib/core/security/input-validation.server.ts:51">
P2: When a configured or self-hosted destination is a private hostname, the new profile does not reach the connect-time lookup, so the request is still rejected even after the destination is allowlisted. Make the guarded lookup evaluate each resolved address with the request's egress profile instead of applying the unconditional public-only filter.</violation>
</file>

<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:45">
P1: Dynamic `import()` or `require()` of `undici` passes this check, allowing raw requests to bypass the new boundary. Use syntax-aware detection covering all runtime module loads and excluding type-only imports.</violation>
</file>

<file name="apps/sim/lib/core/config/env-flags.ts">

<violation number="1" location="apps/sim/lib/core/config/env-flags.ts:180">
P2: Self-hosted startup logs now emit the configured private hostnames and CIDRs verbatim. If application logs leave the deployment, this discloses internal network topology; log only that the allowlist is configured.</violation>
</file>

<file name="apps/sim/lib/core/security/input-validation.ts">

<violation number="1" location="apps/sim/lib/core/security/input-validation.ts:1126">
P2: When a vendor hostname is present in `EGRESS_ALLOWED_HOSTS`, this helper accepts HTTP and can downgrade ServiceNow, Workday, or Databricks requests despite their HTTPS-only contracts. Enforce `https:` in this vendor-specific helper before applying the general configured-endpoint policy.</violation>
</file>

<file name="apps/sim/lib/core/security/egress-end-to-end.server.test.ts">

<violation number="1" location="apps/sim/lib/core/security/egress-end-to-end.server.test.ts:78">
P3: This test rejects for the wrong reason, so it does not verify the property its comment claims. With a plain `http://` URL the `contentFetch` profile rejects via `insecureHttp: 'never'` (insecure-scheme) regardless of the allowlist, so the assertion would still pass even if `contentFetch` started honoring EGRESS_ALLOWED_IP_RANGES. Use `https://${host}:${port}/` so the only difference from the `requestTarget` case is the allowlist, isolating the 'allowlist does not extend to content provenance' behavior the test is meant to guard.</violation>
</file>

<file name="packages/testing/src/mocks/env-flags.mock.ts">

<violation number="1" location="packages/testing/src/mocks/env-flags.mock.ts:129">
P2: When a test sets `isHosted` together with an egress allowlist or legacy flag, this mock still supplies that configuration to the egress policy, unlike production. Make all three getters return `undefined` or `false` while hosted, including their reset implementations.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/sim/lib/mcp/domain-check.ts
Comment thread scripts/check-egress-boundary.ts Outdated
Comment thread package.json Outdated
Comment thread apps/sim/lib/internal/stt/operations.ts Outdated
Comment thread apps/docs/content/docs/platform/self-hosting/security.mdx Outdated
Comment thread apps/sim/lib/core/security/input-validation.test.ts
Comment thread apps/sim/lib/core/security/input-validation.server.ts
Comment thread apps/sim/lib/core/config/env-flags.ts
Comment thread apps/sim/lib/core/security/input-validation.ts
Comment thread apps/sim/lib/core/security/egress-end-to-end.server.test.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

6 issues found across 148 files

Confidence score: 2/5

  • apps/sim/lib/core/security/input-validation.server.ts does not reapply the egress profile after guarded fetch redirects, allowing the contentFetch OAuth path to reach public HTTP or denied ports; enforce the profile on every redirect hop.
  • apps/sim/lib/mcp/pinned-fetch.ts validates the first hop for self-hosted MCP OAuth discovery with the wrong contentFetch restrictions, blocking configured private or plain-HTTP endpoints even when the allowlists permit them; align first-hop validation with self-hosted egress policy.
  • packages/security/src/egress.ts can allow an unparseable resolved address when it matches an allowlist or legacy-private rule, creating a validation bypass; reject malformed addresses before applying any vouching rule.
  • apps/sim/lib/core/security/egress/profiles.ts synchronously rejects HTTP self-hosted URLs before DNS can establish an allowed IP-range match, preventing permitted destinations from working; defer liftable pre-DNS denials or use asynchronous validation.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/core/security/input-validation.server.ts">

<violation number="1" location="apps/sim/lib/core/security/input-validation.server.ts:598">
P1: When a guarded fetch follows a redirect to a hostname, this call does not reapply the egress profile. The `contentFetch` OAuth path can therefore follow public HTTP or denied-port redirects, while self-hosted allowlisted hostname redirects are rejected by the public-only lookup; validate every hostname hop with the profile before dispatching it.</violation>
</file>

<file name="apps/sim/lib/mcp/pinned-fetch.ts">

<violation number="1" location="apps/sim/lib/mcp/pinned-fetch.ts:301">
P1: When OAuth discovery starts from a self-hosted MCP endpoint, this validates the configured first hop as `contentFetch` and blocks its private or plain-HTTP address, even when `EGRESS_ALLOWED_HOSTS`/`EGRESS_ALLOWED_IP_RANGES` permit the MCP server. Preserve the configured first hop under `MCP_EGRESS_PROFILE`, then apply `OAUTH_EGRESS_PROFILE` only to response-derived authorization-server URLs.</violation>
</file>

<file name="packages/security/src/egress.ts">

<violation number="1" location="packages/security/src/egress.ts:435">
P2: When `evaluateAddress` receives an unparseable resolved address, an allowlisted or legacy-private destination is allowed instead of rejected. Validate the address before applying any vouching rule so malformed input cannot bypass the egress classifier.</violation>
</file>

<file name="apps/sim/lib/core/security/egress/profiles.ts">

<violation number="1" location="apps/sim/lib/core/security/egress/profiles.ts:91">
P2: When a self-hosted hostname is permitted only by `EGRESS_ALLOWED_IP_RANGES`, synchronous `validateExternalUrl` rejects its HTTP URL before DNS can prove the range match. Defer liftable pre-DNS denials or use asynchronous validation so these configurations can use the documented IP-range allowlist.</violation>
</file>

<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:61">
P2: A comment, string, or type query containing `import('http')` is reported as a dynamic import because this regex scans raw source. Strip comments and strings or parse the TypeScript AST before applying the transport rule, otherwise documentation and type-only code can fail the CI boundary check.</violation>
</file>

<file name="apps/sim/lib/core/security/pinned-fetch.server.test.ts">

<violation number="1" location="apps/sim/lib/core/security/pinned-fetch.server.test.ts:199">
P2: The test title says it verifies a redirect to a private IP outside the allowlist is blocked, but the redirect target 169.254.169.254 is cloud metadata, which `checkResolvedEgress` blocks unconditionally and independently of any allowlist. If the configuredEndpoint allowlist logic regressed to reach any private address whenever a range is set, this test (and the other two allowlist tests, which only use addresses inside 10.0.0.0/8) would still pass. Point the redirect at a genuine private address outside the allowlist, e.g. http://192.168.1.5/, so the pinned-fetch suite actually guards the PR's core guarantee that destinations must be named in EGRESS_ALLOWED_IP_RANGES.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/sim/lib/core/security/input-validation.server.ts
Comment thread apps/sim/lib/mcp/pinned-fetch.ts Outdated
Comment thread packages/security/src/egress.ts Outdated
Comment thread apps/sim/lib/core/security/egress/profiles.ts
Comment thread scripts/check-egress-boundary.ts Outdated
Comment thread apps/sim/lib/core/security/pinned-fetch.server.test.ts
…ions (#7200)

Issue #7200 reports "HTTP connection not working in Docker". The screenshot
shows the real failure: `url must use https:// protocol` on
`http://host.docker.internal:7274/...`. Sim refused the URL before opening a
socket, and the message pointed at the wrong cause — switching to https would
have failed too, on the private-address check one step later.

Plain http was permitted only for a literal `localhost` or loopback IP. In a
container loopback is the one address guaranteed useless, so the carve-out
existed exactly where it could not help. Behind it, the DNS filter dropped every
private address with no operator opt-out, which is why self-hosted Sim could not
reach a LAN vLLM, a Jupyter server, GitHub Enterprise, or a sibling container by
service name either.

The cause was structural: there was no policy object, just a hardcoded default
plus four incompatible escape hatches grown one per use case — `allowHttp`
(protocol only), a hardcoded `isLocalhost && !isHosted`, a hand-threaded
`allowRedirectToIp` for MCP, and `ALLOW_PRIVATE_DATABASE_HOSTS` for one
validator. They disagreed. `allowHttp: true` relaxed the protocol gate while the
address gate refused the host anyway, so vLLM and Jupyter on a LAN were broken
despite passing it.

Replaces all four with one policy:

- `@sim/security/egress` — a pure policy value and two decision functions. No
  DNS, no env, no deployment-posture global, so both postures are testable in
  one file with no module mocking. The hosted branch of the guard had no
  coverage before, because `isHosted` is a module const pinned to `false`.
- `lib/core/security/egress` — profiles keyed on where the URL came from:
  `configuredEndpoint`, `requestTarget`, `databaseHost`, `contentFetch`,
  `proxy`. Provenance is what determines trust, and `contentFetch` — the class
  where SSRF is actually exploited — never consults the allowlist, even on a
  deployment that allowlisted its whole internal range.
- The profile is required at all ~190 call sites and travels on
  `SecureFetchOptions`, so every redirect hop is judged by the policy the
  request started under. `allowHttp` and `allowRedirectToIp` are gone.

Operators name destinations with `EGRESS_ALLOWED_HOSTS` and
`EGRESS_ALLOWED_IP_RANGES`. Naming one permits plain http to it and lifts the
blocked-port list for it, because those are one decision about one host. Cloud
metadata endpoints stay blocked however broad the allowlist is, and both
variables are ignored on the hosted platform.

`ALLOW_PRIVATE_DATABASE_HOSTS` keeps working as a deprecated alias that expands
to the private space it always stood for, so existing deployments are unaffected;
it is dropped from the docs in favour of naming specific destinations.

Also removes what the consolidation made redundant: nine dead exports, three
copies of "validate then host-suffix allowlist", a duplicate of
`validateJiraCloudId`, and the second `validateUrlWithDNS` in the API block
handler, which discarded its pinned address and checked a pre-templating string
that was not the URL dialled. Adds `check:egress-boundary` so a raw HTTP
transport outside the guard fails CI.

Compose files gain `host.docker.internal:host-gateway`; without it the hostname
does not resolve on Linux at all, which was a third distinct error for one cause.
@waleedlatif1
waleedlatif1 force-pushed the investigate/http-docker-7200 branch from 80372be to 0a50b90 Compare August 29, 2026 04:44
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

…lling

Two more from review, both verified with probes first.

The deferral added last round applied to every liftable refusal whenever an IP
range was configured — literals included. A literal has already been judged
against its own address, so a lookup can add nothing, and deferring accepted
literals outside every configured range: with only `10.0.0.0/8` allowlisted,
`https://192.168.1.1` came back valid. It now defers hostnames only.

`matchesRangeAllowlist` compared the raw parsed address, so a resolver answering
with `::a00:1` — which is 10.0.0.1 — was refused by a `10.0.0.0/8` entry. It
over-blocks rather than under-blocks, but an allowlisted destination became
unreachable depending on what DNS returned. `canonicalAddress` already existed
for the metadata comparison and simply was not used here.

Folding needed a carve-out the metadata path did not: `::` and `::1` are the
unspecified and loopback addresses, not an IPv4 carried inside IPv6. Folding
`::1` to `0.0.0.1` would have let a `0.0.0.0/8` entry match loopback and stopped
`::1/128` matching it. Both directions are pinned by tests, alongside the four
spellings an IPv4 range must accept.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

2 issues found and verified against the latest diff

Confidence score: 3/5

  • scripts/check-egress-boundary.ts can misclassify a quote inside a regex literal, causing stringRanges to suppress a later transport-load match and potentially miss an egress-boundary violation; tokenize with a JavaScript/TypeScript parser or make the scanner regex-aware.
  • apps/docs/content/docs/platform/self-hosting/security.mdx omits proxy provenance from the security table, which could lead operators to believe EGRESS_ALLOWED_* can permit its outbound requests; add a row documenting that proxy is public-only and ignores both allowlists.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:113">
P2: When a regex literal contains a quote before a transport load, `stringRanges` treats it as a string delimiter and suppresses the later match. Tokenize with a JavaScript/TypeScript parser or make the scanner regex-aware.</violation>
</file>

<file name="apps/docs/content/docs/platform/self-hosting/security.mdx">

<violation number="1" location="apps/docs/content/docs/platform/self-hosting/security.mdx:145">
P2: The table omits the new `proxy` provenance even though proxy URLs are outbound requests. Since `proxy` is public-only and ignores both allowlists, add a row so operators do not assume `EGRESS_ALLOWED_*` can permit an internal proxy.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread scripts/check-egress-boundary.ts Outdated
Comment thread apps/docs/content/docs/platform/self-hosting/security.mdx

@cubic-dev-ai cubic-dev-ai 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.

1 existing issue remains and 4 new issues found across 150 files

Confidence score: 2/5

  • packages/security/src/egress.ts does not canonicalize DNS64/NAT64 metadata addresses, so an allowlisted hostname can bypass the metadata exception and reach cloud metadata services — normalize NAT64-mapped addresses before classification.
  • apps/sim/lib/mcp/oauth/auth.ts follows redirects from a configured MCP host under selfHostedService, allowing malicious OAuth metadata to redirect requests to loopback or other disallowed destinations — apply the contentFetch restrictions to these redirects.
  • scripts/check-egress-boundary.ts misses both configured or response-derived URLs passed to global fetch and transport imports inside ${...} template expressions, allowing runtime egress paths to bypass DNS classification, connection pinning, and the CI guard — inspect fetch call sites and parse template expressions with a lexer or parser.
  • apps/docs/content/docs/platform/self-hosting/security.mdx describes proxyUrl handling too broadly: proxy URLs bypass allowlists and private-address checks, which could mislead operators configuring security controls — clarify the limitation or document proxies as public-only.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/docs/content/docs/platform/self-hosting/security.mdx">

<violation number="1" location="apps/docs/content/docs/platform/self-hosting/security.mdx:158">
P2: When an HTTP block supplies `proxyUrl`, its URL ignores both allowlists and rejects private addresses. Qualify this blanket statement or document proxies as public-only, otherwise operators will add an allowlist that cannot make a private proxy work.</violation>
</file>

<file name="packages/security/src/egress.ts">

<violation number="1" location="packages/security/src/egress.ts:297">
P1: When a DNS64/NAT64 resolver returns the standard `64:ff9b::/96` representation of a cloud metadata address, an allowlisted hostname bypasses the metadata exception because `isMetadataAddress` does not canonicalize NAT64 forms. Canonicalize the supported NAT64 representation to the embedded IPv4, or otherwise reject metadata-equivalent translated addresses before applying any vouching rule.</violation>
</file>

<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:15">
P1: When a configured or response-derived URL is passed to global `fetch`, this check reports no violation because it only scans imported transports. Those requests bypass the new DNS classification and connection pinning, so inspect non-same-origin fetches or route them through the guarded fetch instead of exempting all bare `fetch` calls.</violation>

<violation number="2" location="scripts/check-egress-boundary.ts:113">
P2: A transport import inside a template interpolation is treated as template text and skipped, allowing valid runtime code to bypass this CI guard. Use a lexer or parser that distinguishes template text from `${...}` expressions.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Fix all with cubic | Re-trigger cubic

Comment thread packages/security/src/egress.ts
Comment thread scripts/check-egress-boundary.ts
Comment thread apps/docs/content/docs/platform/self-hosting/security.mdx Outdated
Comment thread scripts/check-egress-boundary.ts Outdated
A DNS64 resolver returns an IPv4 destination wrapped in the RFC 6052 well-known
prefix, so `64:ff9b::a9fe:a9fe` is the metadata endpoint. Canonicalization did
not recognise that, and an allowlisted hostname resolving there was permitted —
probed and confirmed. Narrower than it first looked: `isPrivateIp` already
rejects every NAT64 form, so an ordinary destination was never exposed; the hole
was only for a vouched one, where the class check is skipped and the metadata
exception is all that stands in the way. Folding it also makes an operator's
IPv4 range match the NAT64 spelling of an address inside it.

The boundary check is parsed with the TypeScript AST instead of matched with a
regex. Two rounds found holes in both directions — a comment or string naming a
transport reported a violation that did not exist, and a regex literal
containing a quote hid one that did — which is what a scanner that does not
understand the grammar will keep doing. A template interpolation was the third.

Parsing surfaced a false positive the regex never had: `import { type X } from
'undici'` is elided under `verbatimModuleSyntax: false`, so it cannot load
anything. Elision is now modelled properly — a default or namespace binding
keeps an import alive, an all-type named import does not. Eleven forms verified
by probe.

That needs `@typescript/typescript6` declared at the root rather than relied on
by hoisting from apps/sim. It adds no new `tsc` bin, and check:native-typecheck
still reports 7.0.2.

Docs: the provenance table was missing `proxy`, and the line about naming a
destination read as though the allowlist reaches everything. It does not reach a
content fetch or a proxy — a proxy must be public, since it decides where every
other request may go.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

1 existing issue remains and 5 new issues found across 151 files

Confidence score: 2/5

  • apps/sim/lib/mcp/pinned-fetch.ts keeps MCP_EGRESS_PROFILE active across cross-origin redirects, so OAuth discovery can reach an allowlisted private or plain-HTTP destination controlled by metadata; reapply destination validation and egress policy at every redirect hop.
  • apps/sim/lib/core/security/input-validation.server.ts rejects plain-HTTP destinations that were already permitted through EGRESS_ALLOWED_IP_RANGES, which can break valid configured endpoints; use the validated resolved address for the initial pinned target.
  • scripts/check-egress-boundary.ts misses direct http2/node:http2 sockets and permits raw server-side fetch() calls to bypass the boundary check, leaving unguarded outbound paths; cover both transports and require explicit handling for non-same-origin fetches.
  • packages/security/src/egress.ts incorrectly rejects non-loopback addresses resolved from localhost when allowLoopback is enabled, while apps/docs/content/docs/platform/self-hosting/security.mdx overstates the outbound-request guarantee; apply the loopback carve-out only to the resolved address and update the documentation.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:21">
P1: The bare-`fetch()` exemption lets outbound requests bypass this egress-boundary check, including configured/private destinations that are not same-origin calls. Detect raw server-side `fetch()` calls and require an explicit guarded wrapper or a narrowly scoped exemption for each intentional exception.</violation>

<violation number="2" location="scripts/check-egress-boundary.ts:48">
P1: A direct `http2` import can open an outbound socket without the egress guard and currently passes this check. Add both `http2` and `node:http2` to the transport set.</violation>
</file>

<file name="packages/security/src/egress.ts">

<violation number="1" location="packages/security/src/egress.ts:342">
P2: When `allowLoopback` is enabled, a `localhost` hostname resolving to a non-loopback address bypasses the configured address allowlist and is rejected. Only skip the loopback carve-out for that address, then continue checking `allowPrivate` and `allowedRanges`.</violation>
</file>

<file name="apps/sim/lib/core/security/input-validation.server.ts">

<violation number="1" location="apps/sim/lib/core/security/input-validation.server.ts:502">
P1: When a configured destination is allowed through `EGRESS_ALLOWED_IP_RANGES` and uses plain HTTP, the pinned fetch rejects it before connecting. Check the already validated resolved address for the initial pinned target, while retaining full per-hop validation for redirects.</violation>
</file>

<file name="apps/docs/content/docs/platform/self-hosting/security.mdx">

<violation number="1" location="apps/docs/content/docs/platform/self-hosting/security.mdx:137">
P3: On self-hosted deployments, this is not true for every outbound request: configured endpoints and request targets may reach loopback by hostname, while allowlisted provenances may reach private destinations. Say “By default” and mention the configured-loopback exception so the SSRF boundary is not overstated.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread scripts/check-egress-boundary.ts
Comment thread scripts/check-egress-boundary.ts
Comment thread apps/sim/lib/core/security/input-validation.server.ts Outdated
Comment thread packages/security/src/egress.ts Outdated
Comment thread apps/docs/content/docs/platform/self-hosting/security.mdx Outdated
`node:http2` is an HTTP client that opens outbound sockets, and the boundary
check did not list it — probed and confirmed it passed. `axios`, `node-fetch`
and `got` are also present as transitive dependencies; nothing scanned imports
any of them today, and listing them keeps that true.

The pinned fetch refused an allowlisted plain-HTTP destination before
connecting. That is a regression from last round: making the initial target
re-check the hostname pre-DNS meant a host allowlisted by IP range came back
`insecure-scheme`, since a range match is only visible after resolution. The
caller already holds the resolved address, so it passes it for the initial
target. Redirect hops are still judged afresh.

The loopback carve-out swallowed the allowlist. With `allowLoopback` on, a
`localhost` that resolved to a non-loopback address returned unvouched
immediately rather than falling through, so `10.0.0.5` was refused even with
`10.0.0.0/8` allowlisted. It falls through now, with both directions covered.

Docs: "Sim blocks outbound requests to private, reserved, and loopback
addresses" is not true on a self-hosted deployment — allowlisted provenances
reach private destinations, and loopback is reachable with no allowlist at all.
It says "By default" and names both exceptions.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 151 files

Confidence score: 2/5

  • scripts/check-egress-boundary.ts allows server-side fetch calls with configured or content-derived URLs to bypass DNS classification and IP pinning, weakening the egress security boundary; scan dynamic or external fetch calls with a narrowly scoped exemption.
  • scripts/check-egress-boundary.ts treats type-only undici re-exports as transports, creating false-positive findings for code that TypeScript elides; skip named re-exports whose specifiers are all type-only.
  • apps/docs/content/docs/platform/self-hosting/security.mdx overstates the loopback restriction for databaseHost URLs, which may confuse self-hosting users; qualify the loopback guidance to reflect the documented carve-out.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/docs/content/docs/platform/self-hosting/security.mdx">

<violation number="1" location="apps/docs/content/docs/platform/self-hosting/security.mdx:137">
P2: When a URL is classified as `databaseHost`, `localhost` and loopback literals remain blocked; only configured endpoints, self-hosted services, and request targets get this carve-out. Qualify the loopback sentence by provenance so database and mail/Redis connector users are not told an unreachable configuration works.</violation>
</file>

<file name="scripts/check-egress-boundary.ts">

<violation number="1" location="scripts/check-egress-boundary.ts:21">
P1: When a server-side request uses global `fetch` with a configured or content-derived URL, this check passes it despite bypassing DNS classification and IP pinning. Scan dynamic or external `fetch` calls, with narrow exemptions for same-origin calls, so these requests cannot bypass the egress policy.</violation>

<violation number="2" location="scripts/check-egress-boundary.ts:138">
P2: When a file re-exports only types with `export { type X } from 'undici'`, `node.isTypeOnly` is false and the checker reports a transport that TypeScript elides. Also skip named re-exports whose every export specifier is type-only.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Fix all with cubic | Re-trigger cubic

Comment thread scripts/check-egress-boundary.ts
Comment thread apps/docs/content/docs/platform/self-hosting/security.mdx Outdated
Comment thread scripts/check-egress-boundary.ts Outdated
…ve-out

`export { type X } from 'undici'` sets `isTypeOnly` on the specifiers rather
than the declaration, so the check reported a transport TypeScript elides. The
import path already modelled this; the export path did not. Both do now, with
`export *` and value re-exports still caught.

The docs said a loopback destination is reachable without any allowlist. True
for a configured endpoint, self-hosted service, or request target — not for a
database host, which deliberately has no such carve-out because loopback is
where Sim's own database and Redis listen. As written it would have told someone
configuring Postgres on localhost that an unreachable setup works.
… an allowlist

An audit sweep over the whole diff turned up four things worth fixing in the
policy core and two in the profile layer.

Fail-opens, both IPv6 translation prefixes the address folding missed:

- `::ffff:0:a9fe:a9fe` (RFC 6145 IPv4-translated) was not folded, so a vouched
  destination whose resolver answered with it reached cloud metadata — the
  guarantee that metadata is never liftable did not hold for that spelling.
- `64:ff9b:1::/48` (RFC 8215 local-use NAT64) carries its IPv4 destination at an
  offset the network operator chooses, so it cannot be read off the address at
  all. `ipaddr.js` calls it plain unicast, which made it a route to metadata or
  loopback under even the strict hosted policy. An address whose real
  destination cannot be determined is now refused rather than judged on its
  wrapper.

Policy corrections:

- The loopback carve-out no longer lifts the blocked-port list. It is granted
  without anyone asking for it, and loopback is exactly where Sim's own Postgres
  and Redis listen, so `http://localhost:5432` was reachable from an HTTP block
  on any self-hosted deployment. Only an operator naming a destination lifts
  ports now — a vouch carries the kind that earned it.
- A hostname that says it is loopback is refused before DNS when the policy does
  not permit loopback, so the synchronous validator stops accepting
  `https://localhost/x` on the hosted platform.
- `insecureHttp: 'always'` is capped at `whenVouched` on the hosted platform,
  where nothing is vouched. Software served without TLS is a self-hosted
  arrangement; a hosted deployment should not send a credential in the clear to
  a user-supplied MCP or vLLM host.
- Wildcard allowlist entries are validated like every other entry. `*.foo.com/x`
  and `*..com` were accepted silently and then matched nothing.

Transport:

- An IP-literal redirect target is judged as the literal even when the caller
  supplied a resolved address, because `net.connect` dials a numeric host
  directly and the literal is what the socket reaches.
- The connect-time lookup classifies against the request's own policy instead of
  a hand-rolled private-address filter, so an allowlisted private destination
  the redirect check permitted is no longer stranded at connect.
- 300, 305 and 306 are no longer followed. 305 redirects a request into a
  server-named proxy, which is the one hop a guard must never take.

Provenance corrections: a Vision image resolved from an internal file URL and
Buffer's media probe are presigned URLs against Sim's own storage, so they take
`configuredEndpoint` the way STT and Textract already do — on a self-hosted
deployment with private object storage they were unreachable. Microsoft Word's
upload URL comes out of a Graph response rather than configuration, so it takes
`contentFetch`, matching SharePoint and Windchill.

Also: `policyCanVouch` is gone, since the synchronous callers both need the
narrow predicate; `validateDatabaseHost` classifies each address once instead of
twice; the dead `|| !validation.resolvedIP` conjuncts the discriminated union
made unreachable are removed across ~32 call sites; the boundary check scans
`background/` and `blocks/`; and the docs correct the MCP row, the in-cluster
naming claim, the refusal-message description, and add the upgrade notes.
…he wrapper

A second audit pass found the transition schemes the first one missed. Every one
of these is a real route to an IPv4 destination, and the guard was judging the
wrapper instead:

- RFC 3056 6to4 (`2002:a9fe:a9fe::`) and RFC 5214 ISATAP (`fe80::5efe:169.254.169.254`)
  now fold to the IPv4 they name, so they read as the metadata endpoints they are.
- RFC 4380 Teredo carries two IPv4 addresses — the client's, obfuscated, and the
  relay's — so rather than pick one it is refused, alongside the rest of the
  reserved `::/64` block, which `ipaddr.js` calls plain unicast. `::5efe:7f00:1`
  reached loopback under the strict hosted policy before this.
- A scope id (`fd00:ec2::254%eth0`) named an interface, not a destination, and
  made an address a different string from the one it is.
- Address classification runs on the canonical form, so the folding above reaches
  the private/loopback verdict and not only the metadata comparison. A 6to4
  wrapper around a public address is now correctly reachable.

Also from the same pass:

- An explicit allowlist grant outranks the loopback carve-out. Checking the
  carve-out first made the policy non-monotonic: an operator who named
  `127.0.0.1/32` got *less* than one who named nothing, because the carve-out
  does not lift the port denylist and short-circuited the range match.
- `localhost.` and `*.localhost` are loopback names too (RFC 6761), and a
  trailing dot no longer defeats the host allowlist on either side.
- An operator range naming a translation prefix now matches an address inside
  it; only the folded spelling was being compared.
- A Unicode allowlist entry is refused with a message naming the punycode form,
  rather than being accepted and then matching nothing — a URL hostname is
  always the A-label.
- `proxy` is exempt from the hosted plain-HTTP cap. Its scheme is fixed by the
  protocol rather than by trust, and capping it left `proxyUrl` with no reachable
  configuration on the hosted platform and two contradictory error messages.
- The egress policies are built in `instrumentation-node`, so a malformed
  allowlist entry stops the process at boot naming the setting, which is what the
  docs say and what the lazy cache had stopped doing.
- `sim-setup` validates both allowlists through the same parser at the prompt,
  and now asks for `EGRESS_ALLOWED_IP_RANGES` as well as the hosts.

Docs: the MCP OAuth rule applies only to endpoints on a different origin than
the configured server; an SSO OIDC discovery URL is a configured endpoint, not
content; the HTTP downgrade refusal does not apply to the two provenances that
expect plain HTTP; plain HTTP is capped on Sim Cloud; the NetworkPolicy note is
conditional on `networkPolicy.enabled`.

Tests: `ssrf-guarded-lookup.test.ts` passes a real profile instead of relying on
the unrecognized-profile fallback; the hosted block regains its positive control
and a service-port case; the OAuth block sets its own posture rather than
inheriting the previous describe's.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

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