Blog

Webhook SSRF, closed by a sender that holds nothing

Webhook SSRF closed twice: the process with your secrets never sends, and the process that sends can reach only the public internet.

A block schematic on black. Left: a block labelled backend containing two blocks, build request and sign. A dashed vertical boundary. A small block labelled request crosses it into a block labelled executor. From the executor one arrow leads right to a block labelled public internet; three arrows lead down to a block labelled private network, each cut by a short coral bar.
Bekir İşgörCo-founder
9 min read

Share

Updated 14 September 2026 with request signing, the tenant gate, figures, and what we found when we checked the code against the claims.

When an agent on a Talkif call decides to call your API — a voice agent webhook — two different programs are involved, and neither one is trusted with the whole job; that split is how we close webhook SSRF. The first — the backend that runs your account — looks up the function, merges what the language model asked for with what the call already knows, decrypts your API key, builds the complete HTTP request and signs it. The second — a small executor with no database, no secrets and no permission to reach anything inside our network — receives that finished request, sends it once, and hands back whatever came back. The executor cannot be redirected, cannot follow a hostname to a private address, cannot read more than a megabyte of response, and cannot wait longer than 35 seconds. The backend, which could do all of those things, never makes the call.

A sequence schematic with four lanes: bot, backend, executor, your server. Arrows in order: bot to backend, function name and arguments; a backend self-loop, tenant check, merge, sign; backend to executor, finished request; an executor self-loop, every address public?; executor to your server, one request, no redirects, in coral; dashed returns your server to executor status and body, executor to backend result or error, backend to bot tool result.
One function call, end to end. One pass out, one pass back, and the only request that leaves our network is the coral one.

The model supplies arguments, not the request

A flow function is defined in the dashboard as a structured HTTP request: a URL with path placeholders, a method, headers, and a set of parameters, each marked by where it goes — path, query or body. That definition is what the backend reads at execution time, from the database, by the function's id. Nothing about the request shape comes from the model or from the bot that is running the call. That matches what a tool call is at the provider: in OpenAI's words, "a special kind of response we can get from the model if it examines a prompt, and then determines that in order to follow the instructions in the prompt, it needs to call one of the tools we made available to it" (OpenAI, Function calling) — a request to act, not an act. The bot sends a function name and an argument map, and the backend verifies that the call the arguments belong to is owned by the account that is asking for it before it reads anything else.

Each parameter also carries a source. A parameter the language model is meant to fill comes from the model. A parameter bound to the call — the caller's number, the contact's id, a campaign field — comes from the call context. A static parameter comes from the function definition itself. When the backend merges the model's arguments with the other two, context and static values always win: whatever the model supplied for a bound parameter is discarded before validation. A caller who spends a minute convincing the agent that their customer id is someone else's gets a request built from the id the call was placed with. The merged arguments are validated against the composed schema, split by location, and path values are percent-encoded per segment, so a value of ../admin stays a single path segment rather than climbing out of it.

Only then are the customer's headers decrypted — they are stored encrypted at rest, and the key never leaves the backend — and the request is assembled: final URL, method, headers, query parameters and body as separate fields. That object, and nothing else, crosses to the executor.

The executor can reach the internet and nothing else

The executor is one small program. It has no database connection, no credential store, no cloud role and no permissions on the cluster it runs in; it runs as an unprivileged user on a read-only filesystem, and its only inbound channel is the internal queue it takes work from. Its job is to be the part that can be wrong without anything leaking.

Before it opens a connection it parses the URL and accepts only http and https. It resolves the hostname and checks every address the name returns — not the first one — against the ranges that must never be reached from inside a cloud network: loopback, the three private IPv4 blocks, link-local including the instance-metadata address, the carrier-grade NAT range, and their IPv6 equivalents including IPv4-mapped addresses. If any one address is blocked, the request is refused. OWASP's SSRF prevention guidance describes the same check — "the application will retrieve all the IP addresses behind the domain name provided (taking records A + AAAA for IPv4 + IPv6) and it will apply the same verification" — and adds the second rule we follow: "disable the support for the following of the redirection in your web client in order to prevent the bypass of the input validation" (OWASP Cheat Sheet Series). The executor follows no redirects; a 302 to an internal address is returned to the backend as a status code and goes nowhere.

That check is a check-then-connect, and we should be precise about what it does not close. The HTTP client resolves the name a second time when it connects, so a name that flips from a public address to a private one between the two lookups would pass the code. What closes that is the second layer: a network policy enforced by the kernel on the executor's pod that allows outbound traffic to the internet and denies the same private ranges regardless of what DNS said. OWASP again: network segregation "is highly recommended in order to block illegitimate calls directly at network level itself". The code check exists to give a clear, logged refusal for the ordinary case; the policy exists for the case the code cannot see.

A block schematic. Executor to dns, dns to two blocks both labelled address. Both address arrows cross a dashed vertical line labelled code check. Further right a solid vertical line labelled network policy. Between the lines a coral bracket labelled second lookup. The upper address arrow continues through the policy line to a block labelled public internet; the lower is stopped at the policy line with a cross.
Two layers. The dashed line is the executor's check of every resolved address; the solid line is the kernel's network policy. The coral span between them is the second lookup — the gap only the policy closes.

The rest of the executor's rules are about not being made to do too much. Request bodies and response bodies are each capped at 1 MB. The timeout is whatever the function asked for, clamped between 100 ms and 35 s, with a separate 10 s limit on establishing the connection. Methods are GET, POST, PUT, PATCH and DELETE; anything else is a permanent error. It runs at most 50 requests at once and, when full, refuses immediately rather than queueing — the backend is told, and the agent gets an error it can speak to. On shutdown it stops taking work, waits up to 25 s for requests in flight to finish, and then disconnects, so a deploy does not cut a customer's request off mid-flight.

Webhook signing covers the bytes on the wire

Since June 2026 every request to a customer endpoint can carry a Talkif-Signature header: a timestamp and an HMAC-SHA256 over "{t}.{body}", keyed with a per-account secret that you can rotate with an overlap window, during which both the current and the previous signature are sent. The receiving side is documented at docs.talkif.ai/integrate/webhooks, with the two rules that matter: verify against the raw request bytes, and compare in constant time. The scheme is deliberately the one Stripe's webhooks use, so the reasoning in their documentation applies unchanged: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records", and on the timestamp, "Because this timestamp is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature" (Stripe, Receive Stripe events in your webhook endpoint). Reject a t older than a few minutes and a replayed request is dead on arrival. The backend also strips any header a flow author added that collides with the signature header, case-insensitively, so a definition cannot inject a fake one.

The engineering problem in signing is not the HMAC. It is that the backend signs a body it does not transmit: the executor serialises the body and sends it. If the two programs ever serialised the same JSON object differently — key order, whitespace, the handling of an empty body — the signature would be over one sequence of bytes and the wire would carry another, and every customer's verification would fail behind a wall of HTTP 200s, because the request itself would have gone through fine. The two programs pin the same JSON library configured to emit keys in sorted order, the backend's decision about when a body is sent at all mirrors the executor's, and a test in the executor's CI asserts both facts against the backend's predicate. The comment on that test says what it is for: CI fails there, loudly, instead of every customer's signature verification failing silently.

Two lanes. Top: a block labelled signer, an arrow to a dashed box labelled bytes holding ten square blocks, an arrow to a block labelled signature. Bottom: a block labelled sender, the same ten-block bytes box, an arrow to a block labelled your server. An equals sign between the lanes. One block in the bottom bytes is filled coral, with a dashed coral leader up to the signature block, which is struck through.
The signer and the sender are different programs. The signature is over the top bytes; the wire carries the bottom ones. A single byte out of place — the coral block — and every signature fails behind a wall of HTTP 200s.

It is sent once, and the agent hears the result

There is no retry anywhere in this path. The executor sends once. The backend waits for the reply, with a timeout five seconds longer than the function's own. The bot waits 35 seconds for the backend. A request that fails is classified — retryable (timeout, connection refused, 429, 502–504) or permanent (4xx, blocked address, oversize, bad scheme) — and that classification is returned, but nobody acts on it yet.

That is a choice, not a gap. A person is on the phone. A retry with backoff that takes eight seconds is eight seconds of silence in a conversation; three of them are a hang-up. So the failure goes back to the agent as the result of the tool call — an error object in place of the response — and the node transition the flow author wired still happens, so the model sees the error in the new node and can say so, or try something else, or offer a callback. The classification is there for the callers that can afford to retry: event webhooks and alert webhooks that run outside a live call, which the executor's request format already names as future sources.

Two honest notes on what the dashboard shows. The call timeline records each function by name and duration; it does not today show whether the call succeeded — that is visible in the transcript if the agent said so, and in our logs. And this is a path that was, until recently, lightly used: across production logs from 14 August to 12 September 2026 there were two server-side function executions. The design was built for the traffic we expect, and it has not yet been tested by volume.

Read next
  1. Developers

    Turn detection that doesn't talk over you

    How our voice agent's turn detection decides a caller has finished, when an interruption is real and when to stay quiet, and the incidents behind it.

Questions about this piece? Write to us.