$npx -y skills add utkusen/sast-skills --skill sast-ssrfDetect Server-Side Request Forgery (SSRF) vulnerabilities in a codebase using a three-phase approach: recon (find outbound call sites), batched verify (trace user input to destinations in parallel subagents, 3 sites each), and merge (consolidate batch results). Requires sast/arch
| 1 | # Server-Side Request Forgery (SSRF) Detection |
| 2 | |
| 3 | You are performing a focused security assessment to find SSRF vulnerabilities in a codebase. This skill uses a three-phase approach with subagents: **recon** (find all places that make outbound TCP, DNS, or HTTP requests), **batched verify** (trace whether user-supplied input reaches those call sites, in parallel batches of 3), and **merge** (consolidate batch reports into one file). |
| 4 | |
| 5 | **Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## What is SSRF |
| 10 | |
| 11 | SSRF occurs when an attacker can cause the server to make outbound network requests to an arbitrary destination — including internal services, cloud metadata endpoints, or other external targets — by supplying or influencing the URL, hostname, IP, or port used in a server-side request. |
| 12 | |
| 13 | The core pattern: *unvalidated, user-controlled input reaches the destination argument of an outbound network call.* |
| 14 | |
| 15 | ### What SSRF IS |
| 16 | |
| 17 | - HTTP client calls where the URL or host is built from user input: `requests.get(user_url)` |
| 18 | - Fetching a resource whose location is provided by the client: `fetch(req.body.webhook_url)` |
| 19 | - DNS lookups on a hostname supplied by the user: `dns.lookup(req.query.host)` |
| 20 | - Raw TCP connections to a host/port derived from user input: `socket.connect((user_host, user_port))` |
| 21 | - File-fetching functions used with HTTP/FTP URLs from user input: `file_get_contents($user_url)` |
| 22 | - URL redirectors that forward to a user-supplied destination without validation |
| 23 | - Webhooks, import-from-URL, screenshot services, PDF renderers, image proxies — any feature that fetches a remote resource on behalf of the user |
| 24 | |
| 25 | ### What SSRF is NOT |
| 26 | |
| 27 | Do not flag these: |
| 28 | |
| 29 | - **Open redirects**: Redirecting the browser (HTTP 302) to a user-supplied URL — that's a client-side redirect, not a server-side request |
| 30 | - **XSS via URL**: Rendering a user-supplied URL in an `<a>` tag without escaping — that's XSS |
| 31 | - **IDOR**: Accessing another user's data by changing an object ID — separate vulnerability class |
| 32 | - **Hardcoded outbound calls**: HTTP requests to fixed, fully hardcoded URLs with no user influence — not SSRF |
| 33 | |
| 34 | ### Patterns That Prevent SSRF |
| 35 | |
| 36 | When you see these patterns, the code is likely **not vulnerable**: |
| 37 | |
| 38 | **1. Strict allowlist of permitted destinations** |
| 39 | ```python |
| 40 | ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"} |
| 41 | parsed = urlparse(user_url) |
| 42 | if parsed.hostname not in ALLOWED_HOSTS: |
| 43 | raise ValueError("Destination not allowed") |
| 44 | requests.get(user_url) |
| 45 | ``` |
| 46 | |
| 47 | **2. Allowlist of permitted URL prefixes / schemes** |
| 48 | ```python |
| 49 | ALLOWED_PREFIXES = ["https://api.example.com/", "https://cdn.example.com/"] |
| 50 | if not any(user_url.startswith(p) for p in ALLOWED_PREFIXES): |
| 51 | abort(400) |
| 52 | requests.get(user_url) |
| 53 | ``` |
| 54 | |
| 55 | **3. No user influence on the destination** |
| 56 | ```python |
| 57 | # Destination fully hardcoded — no user input involved |
| 58 | response = requests.get("https://api.thirdparty.com/data") |
| 59 | ``` |
| 60 | |
| 61 | > **Note**: IP blocklists (blocking 169.254.0.0/16, 10.0.0.0/8, etc.) are **not** sufficient protection — they can be bypassed via DNS rebinding, URL encoding, IPv6 notation, decimal IP representation, or redirect chains. Do not treat a blocklist as making a site safe; classify it as Likely Vulnerable. |
| 62 | |
| 63 | --- |
| 64 | |
| 65 | ## Vulnerable vs. Secure Examples |
| 66 | |
| 67 | ### Python — requests |
| 68 | |
| 69 | ```python |
| 70 | # VULNERABLE: URL fully controlled by user |
| 71 | @app.route('/fetch') |
| 72 | def fetch(): |
| 73 | url = request.args.get('url') |
| 74 | response = requests.get(url) |
| 75 | return response.text |
| 76 | |
| 77 | # SECURE: strict allowlist on destination host |
| 78 | ALLOWED = {"api.example.com"} |
| 79 | @app.route('/fetch') |
| 80 | def fetch(): |
| 81 | url = request.args.get('url') |
| 82 | if urlparse(url).hostname not in ALLOWED: |
| 83 | abort(403) |
| 84 | response = requests.get(url) |
| 85 | return response.text |
| 86 | ``` |
| 87 | |
| 88 | ### Python — urllib |
| 89 | |
| 90 | ```python |
| 91 | # VULNERABLE: user controls the URL passed to urlopen |
| 92 | def preview(request): |
| 93 | target = request.GET.get('target') |
| 94 | data = urllib.request.urlopen(target).read() |
| 95 | return HttpResponse(data) |
| 96 | |
| 97 | # SECURE: only allow https scheme to a hardcoded host |
| 98 | def preview(request): |
| 99 | target = request.GET.get('target') |
| 100 | parsed = urlparse(target) |
| 101 | if parsed.scheme != 'https' or parsed.hostname != 'media.example.com': |
| 102 | return HttpResponse(status=400) |
| 103 | data = urllib.request.urlopen(target).read() |
| 104 | return HttpResponse(data) |
| 105 | ``` |
| 106 | |
| 107 | ### Node.js — fetch / axios |
| 108 | |
| 109 | ```javascript |
| 110 | // VULNERABLE: webhook URL comes directly from request body |
| 111 | app.post('/webhook/test', async (req, res) => { |
| 112 | const { url } = req.body; |
| 113 | const res |