Skip to content

refactor(core): unify the outbound fetch — one SSRF-guarded, time-bounded fetch#37

Merged
andrewshell merged 3 commits into
mainfrom
refactor/safe-fetch-timeout
Jul 1, 2026
Merged

refactor(core): unify the outbound fetch — one SSRF-guarded, time-bounded fetch#37
andrewshell merged 3 commits into
mainfrom
refactor/safe-fetch-timeout

Conversation

@andrewshell

@andrewshell andrewshell commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

What

Folds the per-request outbound timeout into the SSRF-guarded fetch and removes the WEBSUB_SSRF_PROTECTION off 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 fetchWithTimeout wrapper) 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)

  • createSafeFetch gains a timeoutMs option; the timeout logic moves inside it and fetch-with-timeout.ts is deleted.
  • Engine + REST/XML-RPC/WebSub plugins stop wrapping with fetchWithTimeout and no longer take requestTimeoutMs; RssCloudConfig.requestTimeoutMs removed.
  • WEBSUB_SSRF_PROTECTION removed — the guard is always on. Loopback/private dev uses the existing trust-split WEBSUB_FETCH_ALLOW_CIDRS / WEBSUB_CALLBACK_ALLOW_CIDRS allowlists (e.g. 127.0.0.0/8). No config path can now reach an unguarded global fetch.
  • Server wiring (apps/server/core.js) builds one safe fetch per trust tier with timeoutMs: config.requestTimeout.

docs:

  • ADR-0003 amended (dated note) recording the always-on guard and the unified fetch.
  • websub.md env-var table drops the toggle row; core/express README examples fixed.

Not a breaking change

@rsscloud/core is unreleased (0.0.0 in the release-please manifest, no core-scoped tags), so removing requestTimeoutMs / the toggle breaks no published consumer — no major bump.

Verification

  • @rsscloud/core: typecheck ✓, lint ✓, 292 tests / 100% coverage
  • Full workspace typecheck + unit suite ✓ (@rsscloud/express rebuilt against the new core API)
  • e2e Docker stack: 151 passing ✓ — exercises the always-on SSRF + timeout wiring through the allow-CIDR path
  • prettier ✓

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Outbound WebSub requests now use always-on protection for unsafe network destinations, with exceptions only via the existing allowlists.
  • New Features
    • Outbound request timing is now enforced consistently through the shared safe fetch layer (with timeout support provided via the injected fetch).
  • Documentation
    • Updated WebSub/SSRF guidance and examples to match the always-on behavior and new configuration approach for fetch/timeout.
  • Tests
    • Updated or removed timeout-specific cases to reflect timeout enforcement moving to the injected fetch path.

andrewshell and others added 2 commits July 1, 2026 10:05
…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>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR removes the SSRF opt-out toggle, makes the guard always-on, and moves outbound timeout handling into createSafeFetch via timeoutMs. The core config, engine, protocol plugins, tests, and documentation are updated to remove requestTimeoutMs and use injected guarded fetches.

Changes

Timeout consolidation and SSRF always-on guard

Layer / File(s) Summary
createSafeFetch timeoutMs implementation
packages/core/src/safe-fetch.ts, packages/core/src/safe-fetch.test.ts
SafeFetchOptions gains timeoutMs; createSafeFetch wraps requests with an AbortController timeout when set, with new tests covering abort behavior and signal composition.
Config removes requestTimeoutMs and SSRF toggle
packages/core/src/config.ts, packages/core/src/config.test.ts, apps/server/config.js
RssCloudConfig/DEFAULT_CONFIG drop requestTimeoutMs; server config removes webSubSsrfProtection and updates related defaults and tests.
Engine uses injected fetch without fetchWithTimeout
packages/core/src/engine/create-core.ts, packages/core/src/engine/create-core.test.ts, packages/core/src/fetch-with-timeout.ts, packages/core/src/fetch-with-timeout.test.ts
fetchWithTimeout is deleted; detectChange calls the injected doFetch directly; related engine tests are removed or reformatted.
Protocol plugins drop requestTimeoutMs
packages/core/src/protocols/rest-plugin.ts, packages/core/src/protocols/websub-plugin.ts, packages/core/src/protocols/xml-rpc-plugin.ts, and their tests
Plugin options no longer accept requestTimeoutMs; notify/verify/distribute paths call doFetch directly; timeout-specific tests are removed.
Server wiring uses guardedFetchOption
apps/server/core.js
guardedFetchOption unconditionally wraps fetch with createSafeFetch; plugin construction no longer passes requestTimeoutMs.
Documentation updates for guard and timeout
apps/server/docs/websub.md, docs/adr/0003-ssrf-egress-guard-on-outbound-fetch.md, packages/core/README.md, packages/express/README.md, apps/e2e/docker-compose.yml
Docs describe the always-on guard, removed env var, and fetch-injection timeout pattern.

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
Loading

Possibly related PRs

  • rsscloud/rsscloud-server#36: Introduces the WebSub/SSRF guard wiring and createSafeFetch-based outbound fetch behavior that this PR refines.

Poem

I hop where the safe fetch glows,
No toggle now on SSRF rows.
A timer in one burrow bed,
Keeps every outbound hop well-led. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main refactor: unifying outbound fetch into one SSRF-guarded, time-bounded path.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/safe-fetch-timeout

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 1c55b29 and 8e6ec6d.

📒 Files selected for processing (20)
  • apps/e2e/docker-compose.yml
  • apps/server/config.js
  • apps/server/core.js
  • apps/server/docs/websub.md
  • docs/adr/0003-ssrf-egress-guard-on-outbound-fetch.md
  • packages/core/README.md
  • packages/core/src/config.test.ts
  • packages/core/src/config.ts
  • packages/core/src/engine/create-core.test.ts
  • packages/core/src/engine/create-core.ts
  • packages/core/src/fetch-with-timeout.test.ts
  • packages/core/src/fetch-with-timeout.ts
  • packages/core/src/protocols/rest-plugin.test.ts
  • packages/core/src/protocols/rest-plugin.ts
  • packages/core/src/protocols/websub-plugin.ts
  • packages/core/src/protocols/xml-rpc-plugin.test.ts
  • packages/core/src/protocols/xml-rpc-plugin.ts
  • packages/core/src/safe-fetch.test.ts
  • packages/core/src/safe-fetch.ts
  • packages/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

Comment thread packages/core/src/safe-fetch.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>

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
packages/core/src/safe-fetch.test.ts (1)

365-431: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct coverage of signal-composition contract; minor duplication across the three tests.

These three tests correctly validate createSafeFetch's AbortSignal.any composition (init-signal, Request-signal, and combined-with-timeout cases), matching the implementation in safe-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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e6ec6d and 80c0937.

📒 Files selected for processing (2)
  • packages/core/src/safe-fetch.test.ts
  • packages/core/src/safe-fetch.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/safe-fetch.ts

@andrewshell andrewshell merged commit 2ae15ef into main Jul 1, 2026
5 checks passed
@andrewshell andrewshell deleted the refactor/safe-fetch-timeout branch July 1, 2026 17:23
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