Sonar's new JS/TS engine: Tracing meaning, not syntax, to hunt down real vulnerabilities

Source: SonarSource•

Sonar's new JS/TS engine: Tracing meaning, not syntax, to hunt down real vulnerabilities

Static analysis grounded in the formal theory of program analysis, rather than in shallow, syntax-driven heuristics, can compute precise data-flow paths through real, unmodified production code.

What theory-grounded static analysis is supposed to buy you

Static analysis covers a range of techniques. Linters sit at one end: they parse the code into a syntax tree and match against shapes in that tree: eval() called with anything other than a string literal, a dynamic value assigned straight into .innerHTML, a shell command assembled by concatenating in a variable, a string literal that looks like a hardcoded API key. That approach is cheap to build and cheap to run, and it catches real bugs, but it only catches the shapes someone thought to encode. It does not know what the code does, only what it looks like.

Further along that spectrum, some tools take a genuine step up and reason over a call graph: which function calls which, and along which edges a value might travel between them. In a language where functions are ordinary values, passed around as callbacks and stored on objects, building an accurate call graph runs into the same chicken-and-egg problem as the analysis it is meant to support: resolving a call site often means knowing what a variable can point to, and answering that usually assumes a call graph already exists. Tools that try to get a call graph cheaply end up approximating it heavily, missing real edges or inventing ones that cannot happen. And even a reasonably complete graph does not, by itself, track which specific call produced which specific return: propagate a value along it without that context, and it can appear to leave through a return that belongs to an entirely different call to the same function.

Data-flow analysis by abstract interpretation is a rigorous mathematical framework for approximating what a program actually does, with formal definitions of what it means for an analysis to be sound or precise. Source, sink, and sanitizer are taint analysis's own vocabulary, built on top of that foundation to ask one specific question: can a value at one program point reach another. An engine built on that theory does not match syntax at all. It computes whether a value at a given source can actually reach a given sink, through whatever the code actually does. Calls are resolved as the analysis goes, not in a separate pass beforehand: a call site's target falls out of what the abstract state already knows about the value being called, sidestepping the chicken-and-egg problem instead of suffering from it, and convergence is guaranteed through joins over the abstract lattice, at some cost to precision.

That distinction stops being academic the moment the code stops looking like a textbook example. A syntactic rule for "tainted value reaches res.send()" written against a five-line example breaks the instant the value gets destructured, repackaged into an object, or routed through a second file, because none of that changes what the tree looks like at any one matched node, it just moves the value somewhere the rule was not written to look. A semantic analysis does not have that problem, because it was never matching a shape in the first place; it is still asking the same question, can this value reach this sink, regardless of how many hops sit in between.

The hard part is real JavaScript and TypeScript

Most backend JavaScript and TypeScript is not written to be easy to analyze. It is written to be maintainable: logic split across modules, request handling decomposed into routing layers and middleware chains, repeated UI or response-building logic factored into shared helper functions that take a single configuration object instead of a long parameter list. Frameworks like Express encourage exactly this shape, and it is good engineering practice. It is also what turns a single tainted value into a moving target: by the time a query parameter reaches wherever it is actually used, it may have passed through several function calls, been repackaged into an object alongside a handful of unrelated fields, crossed a module boundary through an ordinary import, and been reassembled into a new string through template-literal interpolation.

An analysis that only tracks direct assignment within a single function has no chance here. It either misses the finding or, just as often, keeps the finding but cannot reconstruct a path that a human reviewer can actually verify, which is close to useless in practice. Handling this class of code requires modeling destructuring, including destructured, typed function parameters, object-literal shorthand properties as pass-through, positional argument-to-parameter binding across calls, resolution across ES module imports, return-value propagation through several stacked functions, fine-grained tracking inside long template literals with multiple interpolation points, and framework-specific knowledge of what actually constitutes a sink.

The vulnerability below shipped inside a single commit that bundled about ten unrelated tool integrations at once: 62 files, roughly 16,800 lines changed, of which the vulnerable path accounts for a few hundred. That is an ordinary way for this kind of bug to enter a codebase, as one small, unremarkable-looking piece of a much larger, mostly unrelated change. Whoever looks at that diff, a human under deadline pressure or an agent asked to check it for problems, has to give one destructured field passed between two files the same attention as the other sixty files asking for it at the same time. Agents are increasingly good at writing this kind of code and at skimming it for what looks reasonable, and they can also lose the thread in a large diff or state with confidence that something is fine when it isn't. Skimming for what looks reasonable and proving where a value can and cannot end up are different tasks, and it is easy to mistake one for the other.

The FlowiseAI callback below is a concrete example of exactly this terrain, and a demonstration of what the underlying analysis is capable of when all of the above holds together end to end.

Technical details

Component

routes/oauth2/index.ts & routes/oauth2/templates.ts

Endpoint

GET /api/v1/oauth2-credential/callback

Auth required

None

Class

Reflected HTML injection (partially mitigated by an existing sanitization middleware)

Introduced in

A single feature commit bundling unrelated integrations (62 files, ~16,800 lines)

Commit

30c4180d9737f937ec9c943c6241e4039a90e3d4

Status

Fixed independently by the FlowiseAI team

Flowise credentials can go through an OAuth2 authorization-code flow. When the provider redirects back, it hits the endpoint above, implemented in index.ts. The handler reads the query string and, on any failure path, renders an HTML error page describing what went wrong. All snippets are from the commit where the issue was present.

BrowserGET .../callbackindex.tsroute handlertemplates.tsHTML rendererBrowserstate=<payload>generateErrorPage()res.send(html)The request's state parameter travels from the route handler into the template renderer and back out as the response body.

The source

The handler itself is an anonymous callback handed to Express's routing API, not a function called directly anywhere in this file. It only runs when Express invokes it with a real req and res for a matching request, so recognizing req.query as a source here means knowing what router.get(path, handler) does with handler. state, error, and error_description come straight from the query string with no validation.

Building the message

state is folded into a new string through template-literal interpolation, then passed as the second positional argument of generateErrorPage. The engine has to know which parameter binds to which argument here, since the first and third arguments aren't tainted.

Crossing the file boundary

The tainted argument lands in the message parameter, then is repackaged using object-literal shorthand (message,) as one field of a nine-field options object, most of which are hardcoded literals unrelated to the request.

Destructured again, into the sink template

generateOAuth2ResponsePage destructures the object directly in its parameter list, then interpolates message into a ~70-line template literal alongside several untainted interpolations. The returned string bubbles back up through two call frames to index.ts, gets assigned to errorHtml, and is handed to res.send().

Issue explorer · tssecurity:S5131

The real 16-location flow our engine reported, condensed to its 10 meaningful hops. Click a step to see exactly where it lands.

1. Source: req.query.state

index.ts:159

2. Template literal `...${state}`

index.ts:190

3. Argument #2 of generateErrorPage(...)

index.ts:188

4. Parameter `message`

templates.ts:112

5. Shorthand property in options object

templates.ts:118

6. Argument of generateOAuth2ResponsePage(...)

templates.ts:113

7. Destructured from typed parameter

templates.ts:21

8. Template literal `<div class="message">${message}</div>`

templates.ts:69

↑ return value, 2 call frames up

9. Sink: res.send(errorHtml)

index.ts:195

None of the individual steps above are exotic. This is idiomatic TypeScript, the kind any team, or any coding agent, writes every day. That is precisely why it makes a good demonstration: it is the theory doing the same work it does in the literature, applied to code nobody wrote with a scanner in mind. To carry this taint from source to sink, an engine needs, at minimum:

  • Destructuring as a taint-carrying construct, in both const { x } = obj statements and destructured, typed function parameters.
  • Object-literal shorthand properties as pass-through, distinguishing the one tainted field from eight untainted siblings in the same object.
  • Positional argument-to-parameter binding across a call, not just "this function was called with a tainted argument somewhere."
  • Resolution across a plain ES import, treating two files as one call graph rather than two independent units.
  • Return-value propagation through multiple stacked function calls.
  • Fine-grained tracking inside a long template literal, separating the one tainted interpolation from several untainted ones next to it.
  • Framework-aware entry-point knowledge: router.get(path, handler) is what invokes handler with a real req and res at request time, which is what makes req.query a source in the first place, inside a callback nobody calls directly.
  • Framework-aware sink knowledge: res.send() on an Express Response renders as HTML, and that holds a few lines after an unrelated res.setHeader() call, inside a conditional branch, inside an async route handler, inside a try block.

A tool built on regexes or single-function pattern matching does not get past the second step. A tool with only shallow interprocedural support loses the value at the object-literal repackaging, or at the destructured-parameter unpacking. Miss any one of these and the engine either drops the finding or keeps it without being able to justify the path, which, for a reviewer deciding whether to trust the report, amounts to the same failure.

Real-world impact and fix

Flowise runs a global sanitization middleware on every query parameter, backed by sanitize-html, specifically to guard against XSS. Its default configuration strips <script> tags and event-handler attributes, but its allowlist still permits a handful of "safe" tags, including anchors with http/https href values. An attacker-controlled <a href="https://evil.example/login"> survives sanitization intact and renders as a normal link on Flowise's own domain. That is a phishing primitive, not code execution, so real-world impact is low even though the reflected-XSS rule fired exactly as it should: the flow itself is reported correctly, the mitigation just lives one layer up, in code the rule does not need to reason about to be right. The middleware is not bypassed here; its allowlist simply does not cover every context a value like this can end up in.

Try it · GET /api/v1/oauth2-credential/callback

🔗 Renders as live markup (e.g. a clickable link) on the real domain, the actual production behavior before the fix.

Rendered response

Raw HTML source (excerpt)

This sandbox runs the real generateOAuth2ResponsePage, generateErrorPage, and escapeHtml functions from both commits, client-side, against whatever text you type. It also always runs a simplified reproduction of sanitizeMiddleware's default behavior first, matching the real deployment: strip <script> tags and their content, keep a small tag allowlist, only allow http(s) anchors. That is why the impact lands on "phishing primitive," not full script execution, regardless of which template code is active.

The FlowiseAI team fixed this independently before we published. title, statusIcon, statusText, message, and details are now passed through an explicit HTML-escaping function before being interpolated into the template, closing the gap at the template itself rather than relying on an upstream sanitizer to guess the right policy for every downstream sink.

Summary

The payload here is unremarkable, an anchor tag in an error message. What made it worth tracing is that reaching it required treating a query parameter, an options-bag pattern, a destructured typed parameter, a 70-line template literal, and the framework wiring that invokes the handler in the first place, as one continuous pipe, without losing the taint along the way or raising alarms on the eight other perfectly innocent fields traveling through the same object. Destructuring, options objects, long template literals, and a framework silently invoking a handler nobody calls directly aren't edge cases; they're what ordinary TypeScript looks like, whether it's written by hand or by an AI agent. A data-flow engine has to handle all of that precisely, hop by hop, on code exactly like this, not just on the toy examples usually used to demo one.

Date

Event

2025-06-06

Vulnerability introduced (commit 30c4180d9737)

2026-02-19

Discovered by Sonar

2026-02-24

Fixed independently by the FlowiseAI team (commit 917e02eb3673), five days after discovery and before Sonar had reached out

What this article says