fix(egress): let self-hosted deployments reach named private destinations - #7229
fix(egress): let self-hosted deployments reach named private destinations#7229waleedlatif1 wants to merge 20 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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, andapps/sim/lib/internal/azure-data-explorer/client.tscan 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.tscan replay the login form, including$password, to a cross-origin 307/308 redirect; its operations and logout requests can also forwardAuthorization— 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.tsandapps/sim/connectors/sentry/sentry.tsbecause these calls omitredirectPolicy— 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, andapps/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
61aaa0f to
5b03183
Compare
2ee1fc5 to
606ea07
Compare
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Greptile SummaryThis PR replaces several independent outbound-request exceptions with provenance-based egress profiles and configurable private-network allowlists for self-hosted deployments.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains in the eligible follow-up review scope. No blocking failure remains.
|
| 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
Reviews (8): Last reviewed commit: "fix(egress): judge an IPv6 address by th..." | Re-trigger Greptile
There was a problem hiding this comment.
11 issues found across 148 files
Confidence score: 2/5
apps/sim/lib/mcp/domain-check.tsgrants 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.tsmisses dynamicimport()andrequire()calls forundici, 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.tsrejects authorized internalaudioUrlvalues on self-hosted deployments when they resolve to loopback or private storage endpoints, causing transcription downloads to fail; allow the guarded internal destinations throughcontentFetch.apps/sim/lib/core/security/input-validation.tsaccepts HTTP for allowlisted vendor hosts, enabling an unintended downgrade for ServiceNow, Workday, or Databricks integrations; requirehttps: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
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
6 issues found across 148 files
Confidence score: 2/5
apps/sim/lib/core/security/input-validation.server.tsdoes not reapply the egress profile after guarded fetch redirects, allowing thecontentFetchOAuth path to reach public HTTP or denied ports; enforce the profile on every redirect hop.apps/sim/lib/mcp/pinned-fetch.tsvalidates the first hop for self-hosted MCP OAuth discovery with the wrongcontentFetchrestrictions, 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.tscan 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.tssynchronously 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
…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.
80372be to
0a50b90
Compare
|
@greptile review |
|
@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.
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
2 issues found and verified against the latest diff
Confidence score: 3/5
scripts/check-egress-boundary.tscan misclassify a quote inside a regex literal, causingstringRangesto 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.mdxomitsproxyprovenance from the security table, which could lead operators to believeEGRESS_ALLOWED_*can permit its outbound requests; add a row documenting thatproxyis 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
There was a problem hiding this comment.
1 existing issue remains and 4 new issues found across 150 files
Confidence score: 2/5
packages/security/src/egress.tsdoes 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.tsfollows redirects from a configured MCP host underselfHostedService, allowing malicious OAuth metadata to redirect requests to loopback or other disallowed destinations — apply thecontentFetchrestrictions to these redirects.scripts/check-egress-boundary.tsmisses both configured or response-derived URLs passed to globalfetchand 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.mdxdescribesproxyUrlhandling 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
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.
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 existing issue remains and 5 new issues found across 151 files
Confidence score: 2/5
apps/sim/lib/mcp/pinned-fetch.tskeepsMCP_EGRESS_PROFILEactive 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.tsrejects plain-HTTP destinations that were already permitted throughEGRESS_ALLOWED_IP_RANGES, which can break valid configured endpoints; use the validated resolved address for the initial pinned target.scripts/check-egress-boundary.tsmisses directhttp2/node:http2sockets and permits raw server-sidefetch()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.tsincorrectly rejects non-loopback addresses resolved fromlocalhostwhenallowLoopbackis enabled, whileapps/docs/content/docs/platform/self-hosting/security.mdxoverstates 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
`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.
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
3 issues found across 151 files
Confidence score: 2/5
scripts/check-egress-boundary.tsallows server-sidefetchcalls with configured or content-derived URLs to bypass DNS classification and IP pinning, weakening the egress security boundary; scan dynamic or externalfetchcalls with a narrowly scoped exemption.scripts/check-egress-boundary.tstreats type-onlyundicire-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.mdxoverstates the loopback restriction fordatabaseHostURLs, 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
…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.
Fixes #7200.
The bug
The issue is filed as a Docker networking problem. The screenshot shows the real failure —
url must use https:// protocolonhttp://host.docker.internal:7274/.... Sim refused the URL before opening a socket, and the message named the wrong cause: switching tohttpswould have failed too, on the private-address check one step later.Plain http was permitted only for a literal
localhostor 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:
allowHttpisLocalhost && !isHostedallowRedirectToIpALLOW_PRIVATE_DATABASE_HOSTSThey disagreed.
allowHttp: truerelaxed 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, becauseisHostedis a module const the suite pins tofalse.lib/core/security/egress— profiles keyed on where the URL came from, because provenance is what determines trust:configuredEndpointrequestTargetfetchdatabaseHostcontentFetchproxycontentFetchearns 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.databaseHostgets 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.allowHttpandallowRedirectToIpare deleted.Configuration
EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8Naming 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_HOSTSkeeps 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/Cookieby default. Previously the node transport dropped them only when aredirectPolicywas supplied, so the many callers that passed none forwarded credentials to whatever host a redirect named. Keeping them is now an explicitsendCredentialsOnCrossOriginRedirect: 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 secondvalidateUrlWithDNSin 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 intocheck: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 cleanbun run check:audits— 40/40 passapps/simsuite — 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 inputReview 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
EWLoginform carries$passwordthat way, and 30 call sites send a body with redirects enabled.followRedirectsGuardedhas always refused this on the undici path; the node path now matches, withallowCrossOriginBodyonHttpRedirectPolicyas the opt-in. Nothing opts in today.Also fixed after review:
::a9fe:a9fe— the form the URL parser normalizes::169.254.169.254to — bypassed the metadata comparison for an allowlisted destination. Addresses are folded to a canonical IPv4 first.localhostwith a routable address kept the exemption. The address must land on loopback too.ALLOW_PRIVATE_DATABASE_HOSTSfed the shared allowlist, whichrequestTargetalso honors — an upgrading deployment would have handed workflow authors a route into its network. Now scoped todatabaseHost, and it grants "any private address" rather than a hand-written range list that omitted CGNAT (where Tailscale lives).allowHttphad permitted plain HTTP to any host; folding those four sites intoconfiguredEndpointmade it conditional, breakinghttp://vLLM and Jupyter endpoints on Sim Cloud. They use aselfHostedServiceprofile instead.ReplicaUrland the link-preview URL are response- and content-derived, so both moved tocontentFetch.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 returnednullmeaning run with no guard at all, one of them wheneverALLOWED_MCP_DOMAINSwas 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 gainsdenyServicePorts: 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:
allowLoopbackwas computed fromisHostedwhen 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:ALLOWED_MCP_DOMAINSsetALLOW_PRIVATE_DATABASE_HOSTSkeeps 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:
url_privatecontentFetchnever consults the allowlistproxyUrlon a private or loopback addressAnd three that are the same for every deployment:
Authorization,Cookieandproxy-authorizationunlesssendCredentialsOnCrossOriginRedirectis set; previously any caller that passed no redirect policy forwarded them.allowCrossOriginBodyis set.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:5432stays refused untillocalhostis named.