Skip to content

fix(security): route all plugin egress through safeFetch#1441

Open
joelorzet wants to merge 6 commits into
stagingfrom
fix/KEEP-674-route-plugin-egress-through-safefetch
Open

fix(security): route all plugin egress through safeFetch#1441
joelorzet wants to merge 6 commits into
stagingfrom
fix/KEEP-674-route-plugin-egress-through-safefetch

Conversation

@joelorzet
Copy link
Copy Markdown

@joelorzet joelorzet commented Jun 3, 2026

Summary

Closes the raw-fetch SSRF bypass: plugin step files called bare fetch(), so their egress never reached the SSRF guard in lib/safe-fetch.ts. With SAFE_FETCH_ENFORCE on in staging/prod, a user- or attacker-controlled destination could reach link-local (169.254.169.254 IMDS) and RFC1918 hosts through these paths.

Changes

  • Step egress through safeFetch. Route every bare fetch() in plugin step/core files through safeFetch(url, { plugin }) so the guard validates each request (and every redirect hop) and attributes blocks to the calling plugin. Covers the two user-controlled-host paths (discord webhookUrl, blockscout custom-instance URL) plus the fixed-host defense-in-depth sites.
  • Connection-test files stay on raw fetch, guarded on the server. The plugins/*/test.ts connection-test files are reachable from the client-bundled plugin registry, so they cannot import the server-only guard (doing so breaks the client build). Instead, handlePluginTest (lib/db/test-connection.ts) validates every user-supplied type: "url" field with assertUrlIsPublic before the test runs, mirroring the assertConnectionUrlIsPublic pre-flight already used for database connection tests. This closes the blockscout custom-instance SSRF on the "Test Connection" path generically for all plugins. The guard is always-on (it does not honor SAFE_FETCH_ENFORCE), consistent with the DB/RPC pre-flights, so in local dev a localhost instance URL is blocked at "Test Connection" even though execution against it works under shadow mode.
  • Discord webhook validation hardened. The old check was a substring match on discord.com/api/webhooks/, which a URL like http://169.254.169.254/discord.com/api/webhooks/x passes by carrying that string in its path while the host is internal. It now parses the URL and requires https plus a discord.com/discordapp.com (or subdomain) host plus an /api/webhooks/ path, rejecting off-host URLs before any request. safeFetch remains the network-layer backstop.
  • CI guard. Add Forbid raw network egress in plugins (in the lint job): git-greps for bare fetch()/axios/http.request in plugin step files so this cannot regress. Vitest specs and the client-reachable test.ts connection-test files are excluded.
  • Scaffolding. Update the plugin generator template, authoring guides, and agent/command definitions to model safeFetch in step files (and document the connection-test exception), so new plugins are compliant by default.

Tests

  • tests/unit/blockscout-steps.test.ts updated to mock safeFetch (step code no longer touches the fetch global).
  • New tests/unit/discord-send-message.test.ts: the bypass URLs, non-https, and wrong-path are rejected with no egress; a valid webhook routes through safeFetch with { plugin: "discord" }.
  • New tests/unit/plugin-test-url-guard.test.ts: a connection-test url field pointing at an internal address is blocked before the test runs; a public URL is allowed; an empty field is skipped.
  • Existing safe-fetch.test.ts already proves safeFetch/assertUrlIsPublic block 169.254.169.254/RFC1918 in enforce mode, which these paths now inherit.

Full unit suite, pnpm check, and pnpm type-check pass; production build compiles cleanly.

joelorzet added 3 commits June 2, 2026 21:27
Raw fetch() in plugin step and connection-test files bypassed the SSRF
guard in lib/safe-fetch.ts, so a user- or attacker-controlled destination
could reach link-local (169.254.169.254) and RFC1918 hosts even with
SAFE_FETCH_ENFORCE on. Replace every bare fetch() under plugins/ with
safeFetch(url, { plugin }) so the guard validates each request and blocks
attribute to the calling plugin.

Prioritized the two user-controlled-host paths: the discord webhookUrl and
the blockscout custom-instance URL. Also harden the discord webhook check:
the old substring match on "discord.com/api/webhooks/" passed a URL that
carried that string in its path while pointing the host at an internal
address (e.g. http://169.254.169.254/discord.com/api/webhooks/x). Validate
by parsed hostname over https with an /api/webhooks/ path instead.

Update blockscout-steps unit tests to mock safeFetch, and add a discord
send-message test covering the bypass URLs and the safeFetch wiring.
Add a lint-job step that git-greps for bare fetch()/axios/http.request
under plugins/ (excluding *.test.ts, *.md, *.txt) and fails the build,
so plugin egress cannot regress off safeFetch. Pattern avoids \b so it
runs the same on the BSD and GNU regex engines.
Update the plugin generator template, authoring guides, and agent/command
definitions to use safeFetch(url, { plugin }) instead of raw fetch(), so
newly created plugins route egress through the SSRF guard and pass the
new CI egress check by default.
@joelorzet joelorzet requested review from a team, OleksandrUA, eskp and suisuss and removed request for a team June 3, 2026 00:29
The build failed because lib/safe-fetch.ts is "server-only" and the
connection-test files (plugins/*/test.ts) are reachable from the
client-bundled plugin registry (plugins/index.ts), so importing safeFetch
into them pulled server-only into the client graph.

Connection tests run server-side but cannot import the server-only guard
from the client-reachable registry. Revert the nine test.ts files to the
raw fetch global, exclude plugins/*/test.ts from the egress CI guard, and
note the step-file vs connection-test distinction in the scaffolding and
authoring guides. Workflow step files (the SSRF surface this ticket
targets) remain routed through safeFetch and are unaffected.
Connection-test files (plugins/*/test.ts) are reachable from the
client-bundled plugin registry, so they cannot import the server-only SSRF
guard and fetch user-supplied instance URLs (e.g. blockscout's
BLOCKSCOUT_API_URL) with raw fetch. A user could point that at
169.254.169.254 or an RFC1918 host and use the Test Connection result as a
reachability oracle.

Validate every user-supplied url-type field on the server in
handlePluginTest, before the test runs, via assertUrlIsPublic -- mirroring
the assertConnectionUrlIsPublic pre-flight already used for database
connection tests. Generic across all plugins; no change to the
client-reachable test.ts files.
Document, in the handlePluginTest guard comment and the plugin authoring
guide, that assertUrlIsPublic is always-on (it does not honor
SAFE_FETCH_ENFORCE), so Test Connection blocks a localhost/private instance
URL in local dev even though workflow execution against it works under
safeFetch shadow mode. Also normalize prose to avoid double hyphens.
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