Skip to content

Commit ca31ad8

Browse files
authored
docs(js-ts): inline-<script> JSON data-island XSS pattern (#80)
## Summary Add a JavaScript/TypeScript security pattern for safely embedding untrusted JSON into an inline `<script>` data island — a stored-XSS sink distinct from the DOM sinks already in section 3. ## Came from `/retro` sweep on 2026-07-05 of session `44c102fc` (2026-07-04). Finding: B8 (a reusable web-security technique had been captured only in cwd-scoped project-local memory) + B16 (hard-won technique). The class was found and fixed while building a data dashboard. ## Change - New **§3a** with BAD/GOOD examples and a poison-test verification step, covering the three compounding footguns: - `String.replace` interpreting `$'`/`$&`/`` $` ``/`$$` in the *replacement* as substitution patterns (breakout via a `$'` in any value); - first-match-only replacement shipping a blank page when the template reuses the variable name; - case/whitespace bypass of naive `</script>` escaping. Safe form: `replaceAll('<', '<')` + a function replacement + a distinct placeholder token. - Detection pattern **SA-JS-21** (`replaceAll('</script'…`). - Changelog row. ## Test plan - [ ] Skill Validation (markdown/link lint) passes - [ ] Poison payloads (`</script>`, `$'`, `onerror=`) render inert with the GOOD form - [ ] SA-JS-21 regex matches the BAD `replaceAll('</script>', …)` line
2 parents c40ecc8 + 0f66933 commit ca31ad8

3 files changed

Lines changed: 72 additions & 0 deletions

File tree

skills/security-audit/checkpoints.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,13 @@ mechanical:
833833
severity: error
834834
desc: "postMessage with wildcard origin - data exposed to any frame"
835835

836+
- id: SA-JS-21
837+
type: regex_not
838+
target: "**/*.{js,ts,jsx,tsx,mjs,cjs}"
839+
pattern: "replace(All)?\\(\\s*['\"\\x60]</script"
840+
severity: warning
841+
desc: "naive </script> escaping of a JSON data island - use replaceAll('<','\\u003c') + function replacer"
842+
836843
# === NODE.JS SERVER-SIDE SECURITY (Phase 3) ===
837844
- id: SA-NODE-01
838845
type: regex_not

skills/security-audit/references/javascript-typescript-security-features.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,57 @@ element.innerHTML = DOMPurify.sanitize(userHtml);
134134

135135
**Security implication:** DOM XSS (CWE-79) bypasses server-side sanitization because the payload never reaches the server. The `innerHTML`, `outerHTML`, and `document.write` sinks interpret HTML markup, while `location.href` can execute `javascript:` URIs. Always use `textContent` for text output.
136136

137+
### 3a. Embedding Untrusted JSON into an Inline `<script>` (build-time / SSR data island)
138+
139+
Serializing data (some of it third-party — API descriptions, user profiles) into
140+
an inline `<script>` data island is a stored-XSS sink distinct from the DOM sinks
141+
above: the injection happens at **build/render time**, in the string that
142+
interpolates the JSON, not in a browser API. Three footguns compound:
143+
144+
```javascript
145+
// BAD: template.replace('__DATA__', JSON.stringify(data).replaceAll('</script>', '<\\/script>'))
146+
// 1. String.prototype.replace(str, replacement) treats $' $& $` $$ in the REPLACEMENT
147+
// as substitution patterns -> a $' inside any data value splices the template's
148+
// suffix (including a literal </script>) back into the emitted JSON -> breakout.
149+
// 2. replace(str, ...) replaces only the FIRST match. If the template holds two
150+
// identical tokens (`window.__DATA__ = __DATA__;`) it substitutes the variable
151+
// name, shipping a blank page -- a correctness bug that masks the security one.
152+
// 3. Escaping only lowercase `</script>` is bypassable: </ScRiPt>, </script >, <!--
153+
154+
// GOOD:
155+
const n = template.split('__DATA_JSON__').length - 1;
156+
if (n !== 1) throw new Error(`template must contain exactly one __DATA_JSON__ placeholder (found ${n})`);
157+
const payload = JSON.stringify(data).replaceAll('<', '\\u003c'); // still valid JSON; neutralizes every </script> casing, <script, and <!--
158+
const html = template.replace('__DATA_JSON__', () => payload); // exactly one match (asserted above); function replacement disables $-pattern interpretation
159+
```
160+
161+
- Escaping `<` to its unicode form yields valid JSON (`JSON.parse` restores it)
162+
while making it impossible to close the `<script>` element or open a new
163+
tag/comment.
164+
- Use a **function** replacement (`() => payload`) so `$`-sequences in the data are
165+
never interpreted.
166+
- Use a placeholder token **distinct** from the JS variable name (`__DATA_JSON__`,
167+
not `__DATA__`), and **assert it occurs exactly once**`String.replace`
168+
substitutes only the first match, so a duplicated placeholder would silently
169+
ship partially-substituted, broken output.
170+
171+
When rendering those values back into the DOM as markup, escape at every sink
172+
through one centralized helper so no call site is missed:
173+
174+
```javascript
175+
const esc = s => String(s ?? '').replace(/[&<>"']/g,
176+
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
177+
```
178+
179+
**Verify with a poison test:** inject `</script>`, `$'`, and `onerror=` payloads
180+
into the data, render headless, and assert the page is inert and non-blank —
181+
escaping bugs and the blank-page substitution trap both surface only at render.
182+
183+
**Security implication:** Stored XSS (CWE-79). Naive `String.replace` templating
184+
of a JSON data island is exploitable even when no dynamic-HTML DOM sink is used,
185+
and the safe form costs one `replaceAll('<', '\\u003c')` plus a function
186+
replacement.
187+
137188
### 4. `postMessage` Origin Validation
138189
139190
The `postMessage` API enables cross-origin communication. Without origin validation, any page can send messages to your application.
@@ -761,6 +812,7 @@ class AuthService {
761812
| Nested regex quantifiers | `(\+\)\+|\*\)\*|\+\)\*)` | warning | SA-JS-18 |
762813
| strict mode disabled | `"strict"\s*:\s*false` | warning | SA-JS-19 |
763814
| Unvalidated JSON.parse reviver | `JSON\.parse\([^)]+,\s*\(` | warning | SA-JS-20 |
815+
| Naive `</script>` escaping of a JSON data island | `replace(All)?\(\s*['"\x60]</script` | warning | SA-JS-21 |
764816
765817
## Version Adoption Security Checklist
766818
@@ -790,3 +842,4 @@ class AuthService {
790842
| Date | Change | Reason |
791843
|------|--------|--------|
792844
| 2026-03-31 | Initial release | Phase 3 |
845+
| 2026-07-05 | Add §3a inline-`<script>` JSON data-island XSS + SA-JS-21 | Real stored-XSS class found building a data dashboard |

skills/security-audit/scripts/scanners/javascript.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ else
202202
echo "OK: No wildcard postMessage targets detected"
203203
fi
204204

205+
# === Check for naive </script> escaping of a JSON data island (SA-JS-21) ===
206+
echo ""
207+
echo "=== Checking for Naive </script> Escaping ==="
208+
SCRIPT_ESCAPE_HITS=$(scan_js "replace(All)?\(\s*['\"\\x60]</script" 10)
209+
if [[ -n "$SCRIPT_ESCAPE_HITS" ]]; then
210+
echo "WARNING: naive </script> escaping of a data island (stored XSS via \$-patterns / case bypass); use replaceAll('<','\\u003c') + a function replacer:"
211+
echo "$SCRIPT_ESCAPE_HITS" | head -5
212+
WARNINGS=$((WARNINGS + 1))
213+
else
214+
echo "OK: No naive </script> escaping detected"
215+
fi
216+
205217
# === Check for npm audit vulnerabilities ===
206218
echo ""
207219
echo "=== Checking Dependencies ==="

0 commit comments

Comments
 (0)