Files
worldmonitor/todos/043-pending-p1-ssrf-bypass-fetch-agentskills.md
Elie Habib 110ab402c4 feat(intelligence): analytical framework selector for AI panels (#2380)
* feat(frameworks): add settings section and import modal

- Add Analysis Frameworks group to preferences-content.ts between Intelligence and Media sections
- Per-panel active framework display (read-only, 4 panels)
- Skill library list with built-in badge, Rename and Delete actions for imported frameworks
- Import modal with two tabs: From agentskills.io (fetch + preview) and Paste JSON
- All error cases handled inline: network, domain validation, missing instructions, invalid JSON, duplicate name, instructions too long, rate limit
- Add api/skills/fetch-agentskills.ts edge function (proxy to agentskills.io)
- Add analysis-framework-store.ts (loadFrameworkLibrary, saveImportedFramework, deleteImportedFramework, renameImportedFramework, getActiveFrameworkForPanel)
- Add fw-* CSS classes to main.css matching dark panel aesthetic

* feat(panels): wire analytical framework store into InsightsPanel, CountryDeepDive, DailyMarketBrief, DeductionPanel

- InsightsPanel: append active framework to geoContext in updateFromClient(); subscribe in constructor, unsubscribe in destroy()
- CountryIntelManager: pass framework as query param to fetchCountryIntelBrief(); subscribe to re-open brief on framework change; unsubscribe in destroy()
- DataLoaderManager: add dailyBriefGeneration counter for stale-result guard; pass frameworkAppend to buildDailyMarketBrief(); subscribe to framework changes to force refresh; unsubscribe in destroy()
- daily-market-brief service: add frameworkAppend? field to BuildDailyMarketBriefOptions; append to extendedContext before summarize call
- DeductionPanel: append active framework to geoContext in handleSubmit() before RPC call

* feat(frameworks): add FrameworkSelector UI component

- Create FrameworkSelector component with premium/locked states
- Premium: select dropdown with all framework options, change triggers setActiveFrameworkForPanel
- Locked: disabled select + PRO badge, click calls showGatedCta(FREE_TIER)
- InsightsPanel: adds asterisk note (client-generated analysis hint)
- Wire into InsightsPanel, DailyMarketBriefPanel, DeductionPanel (via this.header)
- Wire into CountryDeepDivePanel header right-side (no Panel base, panel=null)
- Add framework-selector CSS to main.css

* fix(frameworks): make new proto fields optional in generated types

* fix(frameworks): extract firstMsg to satisfy strict null checks in tsconfig.api.json

* fix(docs): add blank lines around lists/headings to pass markdownlint

* fix(frameworks): add required proto string fields to call sites after make generate

* chore(review): add code review todos 041-057 for PR #2380

7 review agents (TypeScript, Security, Architecture, Performance,
Simplicity, Agent-Native, Learnings) identified 17 findings across
5 P1, 8 P2, and 4 P3 categories.
2026-03-27 23:36:44 +04:00

3.2 KiB

status, priority, issue_id, tags, dependencies
status priority issue_id tags dependencies
pending p1 043
code-review
security
ssrf
analytical-frameworks

SSRF bypass in fetch-agentskills.tsendsWith check trivially circumvented

Problem Statement

api/skills/fetch-agentskills.ts validates the skill URL with skillUrl.hostname.endsWith('agentskills.io'). An attacker can bypass this with a subdomain they control: evil.agentskills.io passes the check. The function then fetch()es the attacker-controlled URL from Vercel edge compute, potentially reaching internal Vercel network resources (169.254.169.254 metadata endpoint, internal services). This is a textbook SSRF. Additionally, the check applies only to the direct URL — HTTP redirects are NOT validated, so agentskills.io.example.com could redirect to an internal address.

Findings

  • api/skills/fetch-agentskills.ts:42if (!skillUrl.hostname.endsWith('agentskills.io')) — passes for evil.agentskills.io
  • No redirect validation — fetch() follows 301/302 by default
  • No IP-range blocking — 169.254.169.254 (Vercel metadata) reachable if DNS or redirect resolves there
  • Constraint: Vercel edge functions CANNOT use node:dns — full DNS pinning is not feasible without routing through Railway
  • Flagged by: security-sentinel, learnings-researcher (ssrf-toctou-dns-pinning skill, VibeSec-Skill)

Proposed Solutions

Replace endsWith with an exact hostname match against an allowlist and block redirects:

const ALLOWED_HOSTS = new Set(['agentskills.io', 'www.agentskills.io', 'api.agentskills.io']);
if (!ALLOWED_HOSTS.has(skillUrl.hostname)) {
  return Response.json({ error: 'URL must be from agentskills.io' }, { status: 400 });
}
const resp = await fetch(skillUrl.toString(), { redirect: 'manual' });
if (resp.status >= 300 && resp.status < 400) {
  return Response.json({ error: 'Redirects not allowed' }, { status: 400 });
}

Pros: Simple, no DNS needed, blocks subdomain bypass and redirect chains | Effort: Small | Risk: Low

Option B: Railway relay for DNS pinning

Route the fetch through a Railway relay that can use node:dns to resolve the IP, validate it's not private/link-local, then fetch the pinned IP. Pros: Full SSRF protection including DNS rebinding | Cons: Added latency, Railway dependency | Effort: Medium | Risk: Medium

Option C: Block via Vercel Firewall rules only

Rely on Vercel's network-level protection to block fetches to internal IPs. Cons: Not documented, not guaranteed, no defense against subdomain bypass | Risk: High

Technical Details

  • File: api/skills/fetch-agentskills.ts:42
  • PR: koala73/worldmonitor#2380
  • Constraint: Vercel edge cannot use node:dns (from MEMORY.md)
  • Reference: ssrf-toctou-dns-pinning skill, VibeSec-Skill

Acceptance Criteria

  • endsWith check replaced with exact hostname allowlist
  • Redirects blocked with redirect: 'manual'
  • evil.agentskills.io returns 400 (not fetched)
  • 169.254.169.254 is unreachable via this endpoint

Work Log

  • 2026-03-27: Identified during PR #2380 review by security-sentinel