Skip to content

fix: sanitize HTML response before DOM injection to prevent XSS#9

Open
mertcicekci0 wants to merge 1 commit intostellar:mainfrom
mertcicekci0:fix/xss-sanitize-html-response
Open

fix: sanitize HTML response before DOM injection to prevent XSS#9
mertcicekci0 wants to merge 1 commit intostellar:mainfrom
mertcicekci0:fix/xss-sanitize-html-response

Conversation

@mertcicekci0
Copy link
Copy Markdown
Contributor

Summary

Fixes a potential XSS vulnerability in the paywall browser entry.

Previously, packages/paywall/src/browser/entry.tsx injected server responses directly into the DOM using:

document.documentElement.innerHTML = await response.text()

While <script> tags are not executed when inserted via innerHTML, inline event handlers (onerror, onclick, onload, etc.) can still execute.
If the response is tampered with (e.g. MITM or compromised upstream), this could lead to DOM-based XSS.

To mitigate this, a sanitization layer has been added before DOM injection.


Changes

File Description
packages/paywall/src/browser/sanitize.ts Added sanitizeHTML() utility
packages/paywall/src/browser/entry.tsx Sanitize server response before assigning to innerHTML

Sanitization behavior

sanitizeHTML():

  • Parses HTML using DOMParser
  • Removes dangerous elements:
    • script
    • iframe
    • object
    • embed
  • Strips all inline event handler attributes (on*)

Test Plan

  • pnpm --filter @x402-stellar/paywall typecheck passes
  • Paywall still renders protected HTML after successful payment
  • Malicious payloads like:
<img src=x onerror="alert(1)">

are neutralized after sanitization

The paywall entry point was using `document.documentElement.innerHTML`
to inject the server response directly into the DOM. While innerHTML
does not execute <script> tags, inline event handlers (onerror, onload,
etc.) still fire, creating an XSS vector if the response is tampered.

Add a sanitizeHTML utility that uses DOMParser to strip dangerous
elements (script, iframe, object, embed) and all inline event-handler
attributes before injection.
Copilot AI review requested due to automatic review settings March 6, 2026 15:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to reduce DOM-based XSS risk in the paywall browser entry by sanitizing HTML responses before injecting them into document.documentElement.innerHTML.

Changes:

  • Add a sanitizeHTML() helper that parses HTML and removes some dangerous nodes/attributes.
  • Update the browser entrypoint to sanitize text/html responses prior to DOM injection.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/paywall/src/browser/sanitize.ts Introduces a custom HTML sanitization helper.
packages/paywall/src/browser/entry.tsx Applies sanitization to HTML responses before replacing the document content.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +25
const EVENT_HANDLER_RE = /^on/i;

/**
* Parse an HTML string with DOMParser and strip dangerous content
* (script/iframe/object/embed elements and inline event-handler attributes)
* before it is injected into the document via innerHTML.
*/
export function sanitizeHTML(raw: string): string {
const doc = new DOMParser().parseFromString(raw, "text/html");

const dangerous = doc.querySelectorAll("script, iframe, object, embed");
for (const el of dangerous) {
el.remove();
}

const all = doc.querySelectorAll("*");
for (const el of all) {
for (const attr of [...el.attributes]) {
if (EVENT_HANDLER_RE.test(attr.name)) {
el.removeAttribute(attr.name);
}
}
}

return doc.documentElement.innerHTML;
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current sanitizer only removes a small set of elements and strips on* attributes, but it still allows common XSS vectors such as href="javascript:..." / xlink:href, src="javascript:...", formaction/action, srcset with javascript:/data: payloads, and <meta http-equiv="refresh"> redirects. This means the response can still execute script via user interaction or browser behaviors even after sanitization. Consider switching to a well-maintained allowlist sanitizer (e.g., DOMPurify with an explicit config) or, at minimum, additionally strip/validate URL-bearing attributes to disallow dangerous protocols and remove other hazardous elements like meta[http-equiv] and base.

Suggested change
const EVENT_HANDLER_RE = /^on/i;
/**
* Parse an HTML string with DOMParser and strip dangerous content
* (script/iframe/object/embed elements and inline event-handler attributes)
* before it is injected into the document via innerHTML.
*/
export function sanitizeHTML(raw: string): string {
const doc = new DOMParser().parseFromString(raw, "text/html");
const dangerous = doc.querySelectorAll("script, iframe, object, embed");
for (const el of dangerous) {
el.remove();
}
const all = doc.querySelectorAll("*");
for (const el of all) {
for (const attr of [...el.attributes]) {
if (EVENT_HANDLER_RE.test(attr.name)) {
el.removeAttribute(attr.name);
}
}
}
return doc.documentElement.innerHTML;
import DOMPurify from "dompurify";
/**
* Sanitize an HTML string before it is injected into the document via innerHTML.
*
* Uses DOMPurify with an explicit configuration to strip dangerous elements,
* event-handler attributes, and unsafe URL-bearing attributes/protocols.
*/
export function sanitizeHTML(raw: string): string {
return DOMPurify.sanitize(raw, {
// Explicitly forbid high-risk elements that can change page behavior or
// introduce script execution or redirects.
FORBID_TAGS: ["script", "iframe", "object", "embed", "meta", "base"],
// Forbid particularly risky attributes; DOMPurify also strips `on*` handlers
// and unsafe URL protocols (e.g. `javascript:`) by default.
FORBID_ATTR: ["xlink:href", "formaction"],
});

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +9
export function sanitizeHTML(raw: string): string {
const doc = new DOMParser().parseFromString(raw, "text/html");
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/browser is excluded from this package’s tsc typecheck and ESLint runs, so this new sanitizer logic won’t be statically checked in CI via the existing scripts. Given this is security-sensitive code, it would be safer to add a dedicated browser tsconfig/typecheck step (or include these files) so regressions (e.g., DOM API misuse) are caught early.

Copilot uses AI. Check for mistakes.
marcelosalloum added a commit that referenced this pull request Mar 17, 2026
### What

Addresses 6 findings from the [Bug Finder
Report](stellar/internal-agents#136) that
apply to our codebase. Each fix is a separate commit:

| Finding | Severity | Commit | File | Fix |
|---------|----------|--------|------|-----|
| #5 | High | `guard against concurrent payment submissions` |
`useStellarPayment.ts` | Add `useRef(false)` synchronous guard to
prevent double-click / rapid resubmission of the same payment |
| #8 | Medium | `add missing return after insufficient balance error` |
`StellarPaywall.tsx` | Add `return` after setting insufficient-balance
error to prevent fall-through into payment submission |
| #9 | Medium | `use fresh 402 requirements on payment retry` |
`useStellarPayment.ts` | On retry, decode the `PAYMENT-REQUIRED` header
from the 402 response instead of reusing the potentially stale original
prop |
| #12 | Medium | `add fetch timeout to facilitator validation` |
`env.ts` | Add `AbortController` with 10 s timeout to
`validateFacilitators()` fetch so startup doesn't hang if a facilitator
is unresponsive |
| #13 | Medium | `copy handler array at build time` | `builder.ts` |
Snapshot `this.handlers` with spread at `build()` time so later
mutations to the builder don't affect already-built providers |
| #14 | Medium | `guard parseFloat NaN in paywall amount` |
`stellar-handler.ts` | Wrap `parseFloat` result with `Number.isFinite()`
and fall back to `0` to prevent `NaN` from propagating into the paywall
HTML |

### Why

An automated security audit
([stellar/internal-agents#136](stellar/internal-agents#136))
reported 15 findings (7 high, 8 medium). After triaging all 15, 6 are
actionable in our code — the rest are either already fixed or require
upstream changes to [`coinbase/x402`](https://github.com/coinbase/x402).

Closes stellar/internal-agents#136
Copilot AI added a commit that referenced this pull request Mar 31, 2026
… picomatch, and brace-expansion

Agent-Logs-Url: https://github.com/stellar/x402-stellar/sessions/63da46bc-19cc-40f0-a6b1-d4222b0440b7

Co-authored-by: sagpatil <1414227+sagpatil@users.noreply.github.com>
marcelosalloum added a commit that referenced this pull request Mar 31, 2026
…brace-expansion) (#46)

Three sets of npm advisories published March 25–27, 2026 affect
transitive dependencies. All are fixed by updating the lockfile directly
— no `pnpm.overrides` needed.

## Vulnerabilities patched

| Package | Old | New | GHSA | Severity |
|---|---|---|---|---|
| `path-to-regexp` | 8.3.0 | 8.4.1 | GHSA-j3q9-mxjg-w52f,
GHSA-27v5-c462-wpq7 | HIGH / MEDIUM |
| `picomatch` (2.x) | 2.3.1 | 2.3.2 | GHSA-c2c7-rcm5-vvqj,
GHSA-3v7f-55p6-f55p | HIGH / MEDIUM |
| `picomatch` (4.x) | 4.0.3 | 4.0.4 | same | HIGH / MEDIUM |
| `brace-expansion` (1.x) | 1.1.12 | 1.1.13 | GHSA-f886-m6hf-6m8v |
MEDIUM |
| `brace-expansion` (5.x) | 5.0.4 | 5.0.5 | same | MEDIUM |

## Approach

Every parent package already uses a `^` semver range that allows the
patched version:

| Vulnerable dep | Parent | Parent's constraint | Patched version |
|---|---|---|---|
| `path-to-regexp` | `router@2.2.0` | `^8.0.0` | 8.4.1 ✓ |
| `picomatch@2.x` | `anymatch@3.1.3` | `^2.0.4` | 2.3.2 ✓ |
| `picomatch@4.x` | `tinyglobby` | `^4.0.3` | 4.0.4 ✓ |
| `brace-expansion@1.x` | `minimatch@3.1.5` | `^1.1.7` | 1.1.13 ✓ |
| `brace-expansion@5.x` | `minimatch@10.2.4` | `^5.0.2` | 5.0.5 ✓ |

Running `pnpm update path-to-regexp picomatch brace-expansion` refreshes
the lockfile pins to the latest compatible versions (the patched ones).
`pnpm.overrides` would be unnecessary maintenance overhead — the
lockfile is committed and deterministic, so `pnpm install` everywhere
uses the pinned fixed versions.

**Changes:** `pnpm-lock.yaml` only — `package.json` is untouched (beyond
the previous `flatted` override).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sagpatil <1414227+sagpatil@users.noreply.github.com>
Co-authored-by: Marcelo Salloum <marcelosalloum@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants