fix: sanitize HTML response before DOM injection to prevent XSS#9
fix: sanitize HTML response before DOM injection to prevent XSS#9mertcicekci0 wants to merge 1 commit intostellar:mainfrom
Conversation
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.
There was a problem hiding this comment.
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/htmlresponses 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.
| 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; |
There was a problem hiding this comment.
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.
| 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"], | |
| }); |
| export function sanitizeHTML(raw: string): string { | ||
| const doc = new DOMParser().parseFromString(raw, "text/html"); |
There was a problem hiding this comment.
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.
### 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
… 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>
…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>
Summary
Fixes a potential XSS vulnerability in the paywall browser entry.
Previously,
packages/paywall/src/browser/entry.tsxinjected server responses directly into the DOM using:While
<script>tags are not executed when inserted viainnerHTML, 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
packages/paywall/src/browser/sanitize.tssanitizeHTML()utilitypackages/paywall/src/browser/entry.tsxinnerHTMLSanitization behavior
sanitizeHTML():DOMParserscriptiframeobjectembedon*)Test Plan
pnpm --filter @x402-stellar/paywall typecheckpassesare neutralized after sanitization