Skip to content

feat(core): fall back to challenge-bypass sidecar for article enrichment - #171

Merged
joshpanka merged 1 commit into
mainfrom
feat/enrichment-challenge-bypass-fallback
Jul 13, 2026
Merged

joshpanka merged 1 commit into
mainfrom
feat/enrichment-challenge-bypass-fallback

Conversation

@joshpanka

Copy link
Copy Markdown
Contributor

What

Linked-article enrichment (semantic_filter + extract) fetches an item's external_url via the direct pinned-IP path. When that hits a Cloudflare-style wall — the fetch is refused (UNAVAILABLE) or returns an interstitial with no extractable article (MISSING) — retry the article through the existing FlareSolverr challenge-bypass sidecar when SOURCE_CHALLENGE_BYPASS_URL is set, tagging the result INCLUDED_VIA_FALLBACK.

Off by default (empty setting). Reuses the RSS connector's existing sidecar client rather than adding a new one.

Why

RSS feeds of press releases (e.g. athletics coaching-change announcements) carry only the <description> teaser; the judge/extract need the full article. Many of those publisher sites sit behind Cloudflare, so the direct pinned-IP fetch is blocked and enrichment silently falls back to the teaser. The sidecar (headless Chrome) passes the challenge and returns the real body.

Security (SSRF)

The sidecar egresses via a real browser, so the pinned-IP guard that protects the direct path cannot apply, and external_url is untrusted (item-derived). The fallback therefore:

  • pre-validates the host and unconditionally refuses private-/loopback-/reserved-resolving targets (not gated on SOURCE_BLOCK_PRIVATE_IPS) before handing off, then
  • trusts the operator-opted-in sidecar's egress.

This leaves a DNS-rebinding TOCTOU the pinned path doesn't have (the sidecar re-resolves at fetch time) — accepted because the sidecar is a self-hosted, operator-enabled service. Documented in code + the settings comment.

Changes

  • watch_actions/_engine.py: new ExternalContentStatus.INCLUDED_VIA_FALLBACK (schema.json + web generated.ts regenerated, additive enum value).
  • sources/connectors/challenge_bypass.py: extracted a module-level challenge_bypass_fetch() (the ChallengeBypassMixin now delegates; RSS path unchanged) + a SECURITY note that untrusted callers must pre-validate the host.
  • watches/actions/_external.py: the fallback branch + _challenge_fallback() with the SSRF pre-check.
  • conf/settings/base.py: expanded the SOURCE_CHALLENGE_BYPASS_URL comment (now also backs enrichment; states the trade-off).
  • watches/tests_external.py: made existing tests hermetic (sidecar off) + added ChallengeBypassFallbackTests (fires on UNAVAILABLE + MISSING, SSRF pre-check blocks private hosts, sidecar-fail keeps status, solved-but-no-text stays MISSING).

Verification

ruff + format + whitespace, ty, schema drift clean, 361 core tests, 145 CLI tests, web check + smoke + @magpie/schema/@magpie/app typecheck — all green.

Follow-up (not in this PR)

The RSS connector still needs to populate external_url (= the article link) for RSS items to have anything to enrich; that's a separate small change.

joshpanka pushed a commit that referenced this pull request Jul 13, 2026
Address the PR #171 review:
- SSRF blocker: reject non-http(s) / hostless external_url in _challenge_fallback
  BEFORE the sidecar handoff (file:// data: gopher: bypassed the pre-check because
  the host is empty, and the direct path's rejection is what triggers the fallback).
- crash: destination_block_reason now treats an out-of-range port (ValueError on
  parts.port) as BLOCKED instead of raising, so an untrusted URL can't fail the run.
- document the redirect-follow residual (public host 302 -> internal) alongside the
  DNS-rebinding TOCTOU, both accepted for the self-hosted, opt-in sidecar.
- tests: non-web scheme rejected before sidecar, malformed port blocked not crash,
  disabled-sidecar skips the pre-check + solve, happy path never consults the sidecar.
- nits: single DEFAULT_MAX_FETCH_BYTES in sources.connectors.base (was 3 literals);
  direct settings.SOURCE_CHALLENGE_BYPASS_URL (not getattr); clearer enum comment;
  drop a pre-existing `--` in the _external docstring.
…er-kind fetch target

Article enrichment (semantic_filter / extract) now recovers the FULL article for
challenge-protected sources and enriches RSS, so the judge/extract see the article
body instead of just the feed teaser.

- Per-kind fetch target: resolve_external_content fetches `payload.article_url`, a
  computed property on the connector SourcePayload -- base returns `external_url`
  (aggregators like HN point off-site), RssEntryPayload overrides to `url` (an RSS
  entry IS the article). Reddit / Ask HN / HN comments have neither, so they no-op.
  Chosen at evaluation time from stored fields, so it applies to existing feed items
  with no re-poll / backfill and adds nothing to the wire (plain @Property).
- Challenge-bypass fallback: when the direct pinned-IP fetch hits a Cloudflare-style
  wall (UNAVAILABLE / MISSING) and SOURCE_CHALLENGE_BYPASS_URL is set, retry the
  article through the existing FlareSolverr sidecar, tagged INCLUDED_VIA_FALLBACK.
- SSRF: the sidecar egresses via a real browser, so the untrusted article_url is
  pre-validated (http(s) scheme + host via _is_fetchable_web_url, then
  destination_block_reason with block_private_ips=True) before handoff. Residual
  gaps (DNS-rebind TOCTOU, redirect-follow, urlsplit-vs-WHATWG parser differential)
  are documented and accepted for the self-hosted, opt-in sidecar.
- Robustness: primary fetch catches httpx.InvalidURL (a sibling of HTTPError);
  destination_block_reason treats a malformed URL / bad port as BLOCKED not a crash;
  challenge_bypass_fetch tolerates a non-object JSON body -- so a bad/crafted URL or
  a misconfigured sidecar degrades to UNAVAILABLE, never fails the run.
- Refactors: extract module-level challenge_bypass_fetch (mixin delegates); one
  shared FETCH_DEFAULT_MAX_BYTES cap; new ExternalContentStatus.INCLUDED_VIA_FALLBACK.

Off by default (empty SOURCE_CHALLENGE_BYPASS_URL). Adding a value to the strict
ExternalContentStatus response enum means a pinned older CLI must upgrade to render
fallback-enriched runs.
@joshpanka
joshpanka force-pushed the feat/enrichment-challenge-bypass-fallback branch from bc2fe4f to 46570e5 Compare July 13, 2026 16:46
@joshpanka
joshpanka merged commit cd31f8f into main Jul 13, 2026
4 checks passed
@joshpanka
joshpanka deleted the feat/enrichment-challenge-bypass-fallback branch July 13, 2026 16:47
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