refactor(core): unify the outbound fetch — one SSRF-guarded, time-bounded fetch#37
Conversation
…op the SSRF toggle Merge the per-request timeout into the SSRF-guarded fetch as a `timeoutMs` option so every outbound caller holds one object that is both time-bounded and egress-guarded — neither protection can be applied without the other. The engine and the REST/XML-RPC/WebSub plugins stop wrapping with fetchWithTimeout and no longer take `requestTimeoutMs`; fetch-with-timeout is removed. Remove the WEBSUB_SSRF_PROTECTION off switch: the guard is always on, with the trust-split WEBSUB_FETCH_ALLOW_CIDRS / WEBSUB_CALLBACK_ALLOW_CIDRS allowlists as the only exemption (add 127.0.0.0/8 for loopback dev), so no config path reaches an unguarded global fetch. Pre-release refactor (@rsscloud/core is 0.0.0); not a breaking change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Amend ADR-0003 (dated note) with the two follow-up refinements: the removed WEBSUB_SSRF_PROTECTION toggle and the timeout folded into createSafeFetch. Drop the toggle row from the WebSub env-var table and note the guard is always on (allowlist 127.0.0.0/8 for loopback dev). Fix the core/express README plugin examples that referenced the removed requestTimeoutMs option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR removes the SSRF opt-out toggle, makes the guard always-on, and moves outbound timeout handling into ChangesTimeout consolidation and SSRF always-on guard
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant apps/server/core.js
participant createSafeFetch
participant Protocol Plugin
participant Remote Endpoint
apps/server/core.js->>createSafeFetch: guardedFetchOption(allowCidrs)
apps/server/core.js->>Protocol Plugin: createRestProtocolPlugin({fetch: guardedFetch})
Protocol Plugin->>createSafeFetch: doFetch(url, init)
createSafeFetch->>createSafeFetch: apply AbortController timeout + SSRF allowlist
createSafeFetch->>Remote Endpoint: fetch(url, {signal, dispatcher})
Remote Endpoint-->>createSafeFetch: response or abort
createSafeFetch-->>Protocol Plugin: response
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/safe-fetch.ts`:
- Around line 235-242: The timeout handling in safeFetch currently overwrites
any caller-provided abort signal, so upstream cancellations from init.signal or
a Request input are lost. Update the guardedInit construction in safeFetch to
preserve and combine the existing caller signal with the timeout AbortController
signal instead of replacing it, so both timeout and external aborts still
propagate through baseFetch.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6b51b662-73e1-40f7-9cf1-e6ea13b70b96
📒 Files selected for processing (20)
apps/e2e/docker-compose.ymlapps/server/config.jsapps/server/core.jsapps/server/docs/websub.mddocs/adr/0003-ssrf-egress-guard-on-outbound-fetch.mdpackages/core/README.mdpackages/core/src/config.test.tspackages/core/src/config.tspackages/core/src/engine/create-core.test.tspackages/core/src/engine/create-core.tspackages/core/src/fetch-with-timeout.test.tspackages/core/src/fetch-with-timeout.tspackages/core/src/protocols/rest-plugin.test.tspackages/core/src/protocols/rest-plugin.tspackages/core/src/protocols/websub-plugin.tspackages/core/src/protocols/xml-rpc-plugin.test.tspackages/core/src/protocols/xml-rpc-plugin.tspackages/core/src/safe-fetch.test.tspackages/core/src/safe-fetch.tspackages/express/README.md
💤 Files with no reviewable changes (5)
- packages/core/src/fetch-with-timeout.ts
- packages/core/src/fetch-with-timeout.test.ts
- packages/core/src/protocols/xml-rpc-plugin.test.ts
- packages/core/src/protocols/rest-plugin.test.ts
- packages/core/src/config.ts
The timeout branch set signal: controller.signal after spreading ...init, clobbering any caller-provided init.signal (or a Request input's signal), so external cancellations never reached baseFetch. Combine the caller signal with the timeout controller's via AbortSignal.any so both aborts propagate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/core/src/safe-fetch.test.ts (1)
365-431: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCorrect coverage of signal-composition contract; minor duplication across the three tests.
These three tests correctly validate
createSafeFetch'sAbortSignal.anycomposition (init-signal, Request-signal, and combined-with-timeout cases), matching the implementation insafe-fetch.ts(lines 233-251). The bodies are nearly identical boilerplate that could be consolidated via a small helper factory (e.g., a function returning{ baseFetch, safeFetch, getSignal }) parameterized by how the signal/abort is triggered.♻️ Optional: extract shared setup
+function setupTimeoutSafeFetch() { + let signal: AbortSignal | undefined; + const baseFetch = vi.fn((_input: unknown, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise<Response>(() => {}); + }); + const safeFetch = createSafeFetch({ + baseFetch: baseFetch as unknown as typeof fetch, + agentFactory: () => ({}) as never, + lookup: () => {}, + timeoutMs: 1000 + }); + return { safeFetch, getSignal: () => signal }; +}Each test would then call
setupTimeoutSafeFetch()and only vary the invocation/trigger.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/safe-fetch.test.ts` around lines 365 - 431, The three createSafeFetch signal-composition tests are correct but duplicate the same setup and assertions. Extract the shared boilerplate into a small helper (for example, a setup function that creates baseFetch, safeFetch, and captures the composed signal) in safe-fetch.test.ts, then reuse it across the init-signal, Request-signal, and timeout cases. Keep the individual test bodies focused only on how the signal is provided or aborted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/core/src/safe-fetch.test.ts`:
- Around line 365-431: The three createSafeFetch signal-composition tests are
correct but duplicate the same setup and assertions. Extract the shared
boilerplate into a small helper (for example, a setup function that creates
baseFetch, safeFetch, and captures the composed signal) in safe-fetch.test.ts,
then reuse it across the init-signal, Request-signal, and timeout cases. Keep
the individual test bodies focused only on how the signal is provided or
aborted.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8549c3fd-354a-4bdf-a9dc-1d1ed798c5bb
📒 Files selected for processing (2)
packages/core/src/safe-fetch.test.tspackages/core/src/safe-fetch.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/core/src/safe-fetch.ts
What
Folds the per-request outbound timeout into the SSRF-guarded fetch and removes the
WEBSUB_SSRF_PROTECTIONoff switch, so every outbound caller holds a single fetch object that is both time-bounded and egress-guarded — neither protection can be applied without the other.Motivation: the previous split (SSRF baked into an injected fetch; timeout applied per-call by a separate
fetchWithTimeoutwrapper) allowed the two protections to drift apart — "safe without a timeout" or "timeout without safe". Consolidating them closes that class of mismatch by construction.Changes
refactor(core):(code)createSafeFetchgains atimeoutMsoption; the timeout logic moves inside it andfetch-with-timeout.tsis deleted.fetchWithTimeoutand no longer takerequestTimeoutMs;RssCloudConfig.requestTimeoutMsremoved.WEBSUB_SSRF_PROTECTIONremoved — the guard is always on. Loopback/private dev uses the existing trust-splitWEBSUB_FETCH_ALLOW_CIDRS/WEBSUB_CALLBACK_ALLOW_CIDRSallowlists (e.g.127.0.0.0/8). No config path can now reach an unguarded globalfetch.apps/server/core.js) builds one safe fetch per trust tier withtimeoutMs: config.requestTimeout.docs:websub.mdenv-var table drops the toggle row; core/express README examples fixed.Not a breaking change
@rsscloud/coreis unreleased (0.0.0in the release-please manifest, nocore-scoped tags), so removingrequestTimeoutMs/ the toggle breaks no published consumer — no major bump.Verification
@rsscloud/core: typecheck ✓, lint ✓, 292 tests / 100% coverage ✓@rsscloud/expressrebuilt against the new core API)🤖 Generated with Claude Code
Summary by CodeRabbit