DEV Community

jomynn
jomynn

Posted on

Server-Side Request Forgery (CWE-918): From Auto-Detected Finding to Live Exploit to an Honest Tooling Gap — Fully Offline

A full walkthrough of finding, proving, and reporting a real SSRF bug with a deterministic rule engine, a live form-based exploit, an attempted (and explained) automated probe, and a fully offline local LLM — no cloud AI anywhere in the pipeline.
tags: security, javascript, node, webdev

One form field. One "internal-only" endpoint that was never supposed to be reachable. Here's the
full story of a real CWE-918 (Server-Side Request
Forgery) vulnerability, from the moment a rule engine flags it, through a form-only live exploit, to
an honest look at where the platform's own automated probe hits a real limit — all running locally,
with no cloud AI anywhere in the pipeline.

The vulnerable code

The target is a deliberately vulnerable local training app: a "URL preview" tool that fetches
whatever URL you give it, server-side.

app.post('/preview', async (req, res) => {
  const { url } = req.body;
  if (!url) return res.status(400).json({ error: 'url is required' });
  const upstream = await fetch(url, { redirect: 'follow' }); // BUG: no host/scheme allowlist
  const text = await upstream.text();
  res.json({ url, status: upstream.status, body: text.slice(0, 2000) });
});
Enter fullscreen mode Exit fullscreen mode

url comes straight from the request body and goes straight into fetch() — no allowlist on scheme
or destination host. The same server also exposes /internal/admin-status, an "internal-only"
endpoint that isn't linked from any page and simulates something like a cloud-metadata endpoint or an
admin panel. /preview can reach it anyway — and because the response body gets reflected straight
back to the caller, this isn't blind SSRF: the internal endpoint's own flag comes back in the JSON,
a self-contained proof with nothing but a form submission.

Step 1 — A deterministic rule engine catches it, not an LLM

Before any AI touches this finding, the same taint-tracking rule that flags command injection and
path traversal also declares an outbound-request sink, and walks the exact shape this bug takes:

const { url } = req.body       source: untrusted request value
fetch(url, )                   sink: outbound HTTP request
Enter fullscreen mode Exit fullscreen mode

One static scan raises a High, CWE-918 finding automatically — zero manual finding-creation,
zero guesswork. The finding already carries:

  • Evidence — the matched source line, with the exact column of the injection point pinned inside the tainted expression
  • Risk Level — High
  • Security Reference — CWE-918 / OWASP A10:2021 (Server-Side Request Forgery)

The rule engine discovers; the LLM only ever explains an already-verified finding afterward. It
never gets to invent a vulnerability on its own.

Step 2 — Proving it live, with zero extra tooling

The form field is prefilled http://localhost:3007/internal/admin-status. Submitting it returns:

{
  "url": "http://localhost:3007/internal/admin-status",
  "status": 200,
  "body": "{\"internal\":true,\"message\":\"This endpoint should only ever be called by trusted internal callers.\",\"fakeSecret\":\"FLAG{ssrf-reached-internal-endpoint}\"}"
}
Enter fullscreen mode Exit fullscreen mode

body proves the server, not the browser, reached an endpoint that "isn't linked anywhere and isn't
meant to be reachable from outside this server." One request/response pair is already enough evidence
on its own — no server access needed to confirm the bug, because the app's own response is externally
observable proof.

Step 3 — Escalating the payload (and what a rejection does and doesn't prove)

The request body is application/x-www-form-urlencoded, but the payload itself has no &/=
characters, so none of command injection's body-splitting trap applies here — values load and send as
typed:

Payload What it proves
url=http://127.0.0.1:3007/internal/admin-status Loopback literal instead of localhost — same flag comes back
url=http://[::1]:3007/internal/admin-status IPv6 loopback — a third distinct way into the same endpoint, ruling out a naive hostname-string allowlist
url=file:///etc/passwd Rejected with a 502 — but by Node's own fetch (undici), which only supports http/https. That's a runtime-level restriction, not an application-level allowlist, and shouldn't be mistaken for one in a write-up

Three host variations, one important negative result — and the negative result is worth writing up
carefully instead of skipping past it.

Step 4 — Attempting the platform's own Active Test probe — and explaining why it can't confirm this one

The platform does have a real, wired SSRF probe — a GUI checkbox and a CLI flag, backed by an
offline, local out-of-band collaborator (no third-party service, no internet). I set one up and ran
it against /preview's url parameter anyway, on camera, rather than just asserting it wouldn't
work.

It came back with no finding — and the reason is architectural, not a fluke: the probe always injects
its callback URL via a GET query string (ActiveTestUrls.WithQueryParameter, sent through a
GET-only client call). This app's vulnerable parameter exists only on POST /preview's form
body — there's no GET /preview route at all, so the probe's request 404s before the server ever
calls fetch(). No collaborator misconfiguration, no false negative on the app's actual exposure —
just a probe that was built for a different injection channel than this specific endpoint uses.

Two findings would have been the tidy outcome. One finding plus a documented, code-level reason for
why the second attempt didn't land is the honest one.

Step 5 — A fully offline explanation

With the static evidence and the manual proof both attached to the same finding, a local LLM
generates a plain-language narrative from the structured evidence: something like "Untrusted input
reaches an outbound fetch() call with no host/scheme allowlist, letting the server be used to reach
its own internal-only endpoints."
No network call is made. The model reasons over an
already-verified finding; it doesn't discover anything new.

Step 6 — The fix

const ALLOWED_HOSTS = new Set(['api.trusted-partner.example']);

app.post('/preview', async (req, res) => {
  const { url } = req.body;
  let target;
  try {
    target = new URL(url);
  } catch {
    return res.status(400).json({ error: 'invalid url' });
  }
  if (!['http:', 'https:'].includes(target.protocol) || !ALLOWED_HOSTS.has(target.hostname)) {
    return res.status(400).json({ error: 'destination not allowed' });
  }
  const upstream = await fetch(target, { redirect: 'manual' }); // no automatic redirect to an unchecked host
  res.json({ url, status: upstream.status });
});
Enter fullscreen mode Exit fullscreen mode

Allowlist the destination hosts and schemes, resolve and re-validate the actual address before
connecting (reject loopback/link-local/private ranges), turn off automatic redirect-following (or
re-validate the redirect target), and isolate the fetcher's own network egress as defense in depth.

What's honestly not covered

The Active Test tab's SSRF probe is real, wired, and wouldn't be a no-op against every SSRF bug —
just this specific POST-body-only shape. That's a narrower gap than command injection's "no GUI/CLI
entry point at all," but it's still a gap, and I'd rather show it failing on camera with the reason
attached than imply the platform caught two independent confirmations here when it only caught one.

Try it yourself

The target app and the full step-by-step playbook (every click, every payload, every panel) are
linked below if you want to reproduce this end-to-end against your own local copy.

This is an intentionally vulnerable local training app. Never run these techniques against a
system you don't own or don't have explicit written authorization to test.

Try it yourself → https://github.com/sendwavehub/scan-target-demo-apps
Windows Store https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US
Web Site https://Sendwavehub.tech/en/apps/ai-security-studio-4

Top comments (0)