back to research
LARP-2026-019web4 min read

A widget ID dropped unescaped into a generated script

A publisher-widget service built JavaScript by string concatenation and copied the element ID from the query string into a double-quoted literal. Every other value in the response was escaped. The vendor's own site loads the same endpoint.

A price-comparison site offers widgets for publishers. You embed one with a script tag, and the tag points at an endpoint that generates the JavaScript on every request from the query string. Endpoints like that don’t get tested much. The output isn’t HTML, so HTML-focused tooling and HTML escaping both miss it, and the injection point is a string literal inside running code instead of a DOM node.

The unescaped widget ID

The generated script writes the widget’s markup into an element picked by its ID:

jsgenerated script
try {
  document.querySelector("#<widgetId>").innerHTML = "…widget markup…"
} catch(err) {}

<widgetId> comes straight from the query string. The other values in the same response are handled correctly: the markup is HTML-escaped and backslashes are JavaScript-escaped. The ID gets neither.

That’s what I put first in the report. The developer knew the output needed escaping and did it everywhere else in the same template, so this was one missed field and a one-line fix. I only spotted it because I read the whole response instead of fuzzing it.

Payload

A double quote closes the string, ) closes the querySelector call, and after that it’s my code:

httprequest, endpoint path shortened
GET /widgets/category.js?categoryId=1&widgetId=x%22)%3Bwindow.__poc%3Ddocument.domain%3Bwindow.__c%3Ddocument.cookie%3B%2F%2F

The response is still valid JavaScript:

jsresponse
  document.querySelector("#x");window.__poc=document.domain;window.__c=document.cookie;//").innerHTML = "<style>…"
} catch(err) {}

The // comments out the rest of the line, and the } catch(err) {} is on the next line, so the try block still closes. The script parses, runs, and nothing on the page looks broken. That matters. If the injection caused a syntax error, nothing would run.

Loaded the way the widget is meant to be loaded:

htmlembed
<div id="x"></div>
<script src="https://api.<vendor>/widgets/category.js?categoryId=1&widgetId=<payload>"></script>

The vendor’s site

On a publisher’s site this runs in the publisher’s origin, which makes it the publisher’s problem. What made it the vendor’s problem was finding where else the endpoint is loaded. I logged in, opened the vendor’s widget configurator, chose a category widget and generated the preview. The configurator injects this exact endpoint as a script tag on the vendor’s main site:

jsconsole on the configurator page
location.origin  // "https://www.<vendor>"
document.querySelector('script[src*="widgets/category"]').src
// "https://api.<vendor>/widgets/category.js?categoryId=1&widgetId=<generated-id>"

With a crafted ID, the injected code reads back the vendor’s origin and cookies:

textread from inside the injected code
document.domain : www.<vendor>
location.origin : https://www.<vendor>
document.cookie : <consent cookies>…

One thing I said plainly in the report: for that demonstration I added the script tag from the console. The configurator fills in its own generated ID, so what I showed is what happens when the vendor’s origin loads a crafted URL. I didn’t have a way to make it load one.

The vulnerable component is the widget service and the affected one is whatever page embeds it. Those are different security authorities: the bug lives in one service and the damage lands in whichever origin loaded it.

Fix

Escape the ID for a JavaScript string before interpolating it, the same way the other values in that response already are. Better, check it against the character set an element ID actually needs and reject anything else.

When a server builds a script by concatenation, check every interpolated value on its own. Escaping one correctly says nothing about the next, and the one most likely to be missed is the one that looks like an opaque internal ID. For anything embeddable, also find out whose origin it runs in. That answer decides who the finding is about.

Disclosure

Reported to the vendor and fixed. The vendor isn’t named, the endpoint path is shortened, and identifiers in the output are replaced with placeholders.

ilov3c4ndy
back to research