Chapter 6 of 6 · 16 min read
Ranking sinks by reachability
On this page
Severity tells you how bad it is if an attacker reaches a sink — a place where a string stops being data and starts directing behaviour, a query, a path, a template, a redirect — with a hostile string. It says nothing about whether an attacker can reach that sink with a hostile string, and in a service with several sinks fed by one model response, the second question is the one that reorders the list. A sink is only a live risk to the degree that attacker-controlled text can flow into it.
The model response is the immediate source of every field, but the model is not the ultimate source — the model's output is a function of its input, and the question is what feeds that input at each field. A field the model fills by choosing from a fixed set is a different animal from a field the model fills by paraphrasing text lifted out of a customer email, even though both arrive in the same JSON object from the same model call. That difference is reachability, and it is what this chapter ranks on.
Reachability, not severity, sets the order
Take a service where one model response comes back as a JSON object and four different pieces of the application consume it: one field is used to build a SQL query, one chooses an HTTP redirect target, one names an S3 key — the path-like string that identifies a single stored object inside a cloud storage bucket, and the closest thing that storage has to a filename — and one populates an HTML template that renders in an internal dashboard.
The reflex is to order by exploit severity. SQL injection is the scariest word in the list, so SQL goes first, then HTML for the cross-site scripting, then the open redirect, then the S3 key naming last because "it's just a filename." That ordering is defensible on paper and it skips the question that actually decides the plan. So before ranking by how dangerous each sink is, trace each field back to what controls it — the generation-to-sink trace from earlier in this topic, run in reverse. For this service it comes out like this:
| Field → sink | What feeds the model here | Attacker reach | Control present? |
|---|---|---|---|
| SQL query | Model picks from a fixed enum of report types | None — output constrained to a known set | Enum check, effectively yes |
| HTML template | Model paraphrases the body of a customer email | Full — attacker writes the email | None |
| Redirect target | Model chooses from campaign URLs in config | Low — bounded by config list | Weak; no allowlist enforced |
| S3 key | Model derives from customer-supplied order title | Full — attacker names the order | None |
Read the "attacker reach" column and the severity ordering falls apart. The SQL sink, top of the severity list, is fed from a fixed enum: the model can only emit one of a handful of report-type identifiers, and if the consuming code checks the value against that set, there is no path for an attacker to inject SQL because there is no attacker-controlled text reaching the query at all. It is the smallest problem on the board, not the largest, and spending your first two days parameterising a query that was already constrained by an enum is effort spent where the risk is not.
The HTML sink is the opposite. It is fed with text a customer wrote — an email body the attacker composed — and it renders in a staff dashboard, which is the exact shape of the support-console incident from the first chapter of this topic: attacker-authored markup executing inside a privileged internal session. Full reach, privileged sink, no control. That is where the week starts. The S3 key is next, because the order title is equally attacker-controlled and the key naming is a path sink with the traversal and overwrite exposure of the resolver chapter. The redirect is real but bounded, because the model chooses from a config list; the fix is to enforce that list as an allowlist rather than trust the model to stay inside it, which is a smaller, contained job.
The ordering that results — HTML, then S3, then redirect, then SQL — is nearly the reverse of the severity ranking, and it is correct because it follows the attacker. A control's value is the product of how bad the sink is and how reachable it is, and a sink an attacker cannot reach contributes nothing to your risk no matter how severe its worst case sounds.
Multiplication is the right operation there, and saying why makes the ranking something you can redo rather than a rule of thumb to remember. Severity is a statement about the loss if the bad thing happens: what an attacker gets out of this sink once hostile bytes arrive at it. Reachability is a statement about whether hostile bytes arrive at all: how much of what feeds this field a stranger can write. Those are the two halves of an expected loss, and an expected loss is a magnitude multiplied by a likelihood — which is why the two are combined by multiplying rather than by adding or by ordering on one of them. Severity alone is the magnitude with the likelihood silently set to one, and a ranking on severity alone is therefore a ranking that assumes every sink is fully reachable. For three of the four sinks here that assumption is wrong, and one of them is wrong in the strongest possible way: the enum-fed SQL sink has no attacker-controlled input, so its factor is zero, and zero times the scariest severity on the board is still zero.
Reading the reach column as differences rather than as labels shows how coarse the useful judgement is. Two sinks sit at full reach, one at low, one at none. There is no careful gradation to argue about, and there rarely is, because reachability is decided by a structural fact — whether a stranger's text can enter the field's feed — rather than by a quantity. The gap that matters is between none and anything, and after that between bounded and unbounded. A field fed by an enum and a field fed by a config list are both bounded, and the difference between them is only that one of the two bounds is enforced by code. That is why the redirect ranks above SQL despite both being constrained: its bound is a convention, and a convention is a bound nobody checks.
Filling that column is a mechanical exercise, and it is the reverse of the trace that found the sinks. For each field, open the prompt template that produced it and list every value interpolated into the prompt: the system instructions, the tool definitions, the retrieved passages, the request parameters, the record fields. Then ask of each one whether a stranger can decide its bytes, directly or after passing through some intermediary. An email body a customer composed: yes. An order title a customer typed: yes. A campaign URL an operator entered into a config file: no, unless the config is editable from a customer-facing surface. A report-type identifier the model must select from a fixed list: no, provided the consuming code checks the selection. The field's reachability is the strongest answer among its inputs, because one attacker-writable input is enough. That is the whole method, and its output is not an opinion — each cell is a claim about a specific input that somebody can go and check.
The year with no incidents
Someone will point at a year in production with no incidents and argue the risk is theoretical, and the argument deserves a real answer rather than a taxonomy. The absence of a known incident is evidence about two things at once, and they are easy to confuse: it is weak evidence that no one has attacked these sinks, and it is much weaker evidence that an attack would have been detected. For the sinks in this service, detection is exactly what is missing. The S3 traversal writes a log line no one greps. The dashboard XSS executes in a staff browser and leaves a broken-image icon; the exfiltration it performs looks like ordinary outbound traffic. A year with no known incident, on sinks with no detection, is a statement about your monitoring and not about your exposure. You have not observed the safety of the system; you have observed that you would not have noticed.
The reason that is decisive, rather than merely a good retort, is the same test applied to controls earlier in this topic. An observation tells you something about a hypothesis only when the observation would have come out differently had the hypothesis been false. Ask what "no incident report" would look like in the two worlds. In the world where nobody attacked these sinks, there is no report. In the world where somebody did, the S3 traversal wrote a log line nobody greps and the dashboard payload left a broken-image icon, so there is also no report. The observation is identical in both worlds, which means it separates them not at all, and a signal that does not vary with the thing it is supposed to measure carries no information about it. Detection is what would make the two worlds look different, and detection is exactly what these sinks do not have. Notice that this is the same defect as the schema validator that returns valid for the legitimate refund and the cross-tenant one: a constant answer to a question whose truth is varying.
There is a sharper version of the answer, which is that the incentive to attack has
to exist before absence of attack means anything. These sinks became reachable when
the service shipped, but an attacker has to know the dashboard renders email bodies
as HTML, or that the S3 key comes from an order title, before they will craft a
payload. Obscurity buys quiet, not safety, and the quiet ends the day someone
notices — a security researcher, a competitor, a customer who fat-fingers a < into
an order title and watches something strange happen. The year of quiet is the
window you had to fix it, and the honest framing is that you are late rather than
lucky. None of this argues for panic; it argues for the reachable, undetected HTML
sink getting fixed this week rather than being deferred behind a SQL sink that was
never reachable.
A rule that catches the next four sinks
Fixing four sinks by hand is the smaller half of inheriting a service like this, because the service will grow, and the fifth sink will be added by someone who has not read any of this. A remediation that ends at four patches leaves the next engineer free to interpolate the model's next field into the next template. What you leave behind is a rule that makes the pattern hard to reintroduce, and it works at two levels.
The first level is a boundary the model's output cannot cross implicitly. Wrap every model response in a type that is not a string and cannot be passed to a sink without going through a named control, so that "interpolate the model's field into a template" stops being a thing the type system lets you write by accident.
class Tainted:
"""Model-derived text. Cannot reach a sink without a named control."""
def __init__(self, value: str):
self._value = value
def __str__(self):
raise TypeError("Tainted value used as a plain string — "
"call a context control: html_encode / bind_param / "
"s3_key / allowlist_redirect")
def for_html(t: Tainted) -> str: ...
def as_bound_param(t: Tainted): ...
def as_s3_key(t: Tainted, root: str) -> str: ...
def as_redirect(t: Tainted, allow: set[str]) -> str: ...The point is that __str__ raises, so the moment a model-derived value is fed to
an f-string, a template, a query builder, or a path join, the program fails loudly
at that call site instead of quietly doing the dangerous thing. To use the value you
must call one of the context functions, and each function's name records which
grammar it encoded for. This is the same discipline that made prepared statements —
bound parameters, under the name the database drivers use — and auto-escaping
templates the default in web frameworks: the safe path is the one that compiles,
and the dangerous path costs an explicit, greppable, reviewable act.
Two limits on that type are worth knowing before anyone treats it as the answer.
It stops the accidental use and not the deliberate one, since ._value is a
single attribute access away and nothing prevents an engineer in a hurry from
reaching for it — which is the point of the CI check below, and why the check
looks for the sink rather than for the type. The second limit is the one that
surprises people: taint does not survive a boundary that only carries bytes.
Write a tainted value to a database column, a queue, a cache or a log and read it
back, and what returns is an ordinary string, because the marker lived in your
process's type system and the storage layer stores characters. Any hop through
persistence is a place where a value re-enters your program looking trustworthy,
and the trace from earlier in this topic has to be re-run from that read as if it
were a fresh source.
The second level is a check in continuous integration that flags the raw pattern, so
a new sink is caught even if someone reaches around the type. A lint rule, or a
small check that walks the abstract syntax tree (AST) — the parsed, structured form
of your source that the compiler or a tooling library hands you — looking for
model-response fields flowing into f-strings, os.path.join, template raw/safe
markers, or a redirect call, and fails the build with a message pointing at the
control, turns "remember to handle model output" from a tribal instruction into an
enforced one.
# CI check (sketch): fail on model output reaching a sink unescaped.
DANGEROUS_SINKS = {"execute", "raw", "system", "redirect", "innerHTML"}
def flags(node) -> bool:
# true when a value tainted by the model response is passed
# to a known sink without passing through a control function.
return reaches_sink(node, DANGEROUS_SINKS) and not via_control(node)Reachability is a property of the feed, and feeds change
The ordering above is correct on the day the table is drawn and can be wrong a quarter later without any of the four sinks being touched, which is the limit worth carrying out of this chapter.
The two rank columns in that table are worth comparing directly, because of what they have in common and what they do not. Each column uses every rank exactly once, so nothing was inserted and nothing dropped — the same four sinks are being ordered both times. Only one feed changed, and only one of the four rows shows a different feed. Yet the sink that changed did not move by one place, it moved from last to first, and it dragged all three of the others down a place with it. That is the arithmetic consequence of ranking on a product with a factor that can be zero: the SQL sink's severity was always the highest on the board and its reachability factor was the only thing holding its risk down, so the moment that factor stopped being zero it went to the top by the severity it had all along. The other three did not become safer; they were simply overtaken. And none of this required an edit to any of the four sinks. The property that moved was in the prompt template. Two habits keep the table from going stale. The first is to put the reachability judgement into the code rather than into a document, because the reason the SQL sink was safe was the enum, and an assertion next to the query builder that the report type is a member of that set is a line that fails the moment a value arrives from somewhere else. A judgement recorded only in a ticket cannot fail.
The second is to re-run the reverse trace whenever a new field enters the model's input, which is a smaller event than it sounds and is usually one added key in a prompt template — a grep-able, reviewable trigger rather than a scheduled audit nobody schedules.
The taint type above carries this if the value remembers where it came from.
Tainted(value, source="customer_form") lets a control refuse at the boundary: a
query builder that accepts only values whose source is config or enum rejects
the report label before anyone has to notice that the feed changed, and the failure
lands on the engineer adding the feature rather than on whoever is reading logs a
year later.
The rule you leave is a sentence anyone can apply without reading this chapter: model output is untrusted, it is typed as tainted, and it may only reach a sink through a control named for that sink's grammar. That sentence, enforced by a type and a CI check, is what catches the next four sinks — the ones no one has built yet, added by people who will not have the context you spent a week acquiring.
The through-line of this whole topic is a single reframing: the model's output is untrusted input to every downstream interpreter, exactly as a user's raw request always was, and the only reliable defence is to match a context-appropriate control to each sink and enforce it outside the model. That reframing is the one worth carrying out of here, because the sinks will keep changing — a new interpreter, a new tool, a new destination — and the list of sinks is not the lesson.
The lesson is the question you now ask at every call site where a model's string takes effect: which interpreter is on the other side of this call, and what does its grammar require. OWASP's LLM05 entry on Improper Output Handling is the reference to keep beside that question when a new sink turns up and you need the encoding rule for it. The habit of asking is what you take to the sink you have not met yet.
Insecure output handling downstream — read the current version at https://daviddominguez.dev/learn/en/insecure-output-handling-downstream/ranking-sinks-by-reachability