mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-07 00:59:47 +00:00
feat(preflight): hard-fail gate on unescaped node-controlled HTML sinks (#1543)
## Summary Closes the "XSS regression in newly-added sink" class. Follow-up to #1537 (10 stored-XSS sinks in node names) and the post-#1537 audit (TRACE-1, OBS-1, ANL-1 — 3 additional HIGH XSS in files #1537 didn't touch). After those fixes land, the project still has **zero automated catch for the next one**. Every future PR can re-introduce the same class freely. This PR closes that gap with a hard-fail pr-preflight gate that runs at PR-creation time and in CI. ## What the gate does A NEW or MODIFIED line in the PR diff under `public/**/*.{js,html}` is flagged when it matches any of these sink patterns: | Pattern | What it catches | |---|---| | `.innerHTML = \`…\`` / `'…'` | template-literal or string-concat HTML injection | | `insertAdjacentHTML(…, \`…\`)` | DOM-adjacent injection | | `.bindPopup(\`…\`)` / `.bindTooltip(\`…\`)` | Leaflet popup/tooltip injection (the OBS-1 class) | | `.setAttribute('on<event>', …)` | inline event-handler injection | | `.setAttribute('href'\|'src'\|'action'\|'formaction', <interp>)` | `javascript:` URI class | For each flagged line, the gate then walks the dynamic substring (`${…}`, post-`+`, or `setAttribute` value arg) and only fires if it interpolates an identifier from the node-controlled allowlist (`name`, `observer`, `sender`, `pubkey`, `body`, `hash`, …). This keeps the regex off static CSS classes like `text-center`. A flagged line is accepted (no fail) when ANY of: - **(a)** wrapped in `escapeHtml(` / `escapeAttr(` / `safeEsc(` / local `esc(` — the audited helpers - **(b)** a same-PR `test*.js` file DOM-greps the audit payload (`' onfocus=` or `onerror=alert`) AND references the sink file's basename - **(c)** the PR body carries `PREFLIGHT-XSS-OPTOUT: <file>:<line> reason="…"` — explicit author opt-out logged for reviewer attention Otherwise: **HARD FAIL** with `file:line: flagged: <token>` plus a suggested fix. ## Split - **Skill directory** (local, no PR): - `~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh` — canonical gate - `~/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt` — allowlist (27 identifiers, easy to extend without a repo PR) - wired into `~/.openclaw/skills/pr-preflight/scripts/run-all.sh` - **This PR** (in repo): - `testdata/preflight-xss/` — fixtures (`bad-1..bad-3`, `good-1..good-2`, `test-good-2.js`) - `scripts/check-xss-sinks.sh` — local mirror of the canonical gate, so CI can exercise the gate without depending on the skill dir - `test-preflight-xss-gate.js` — Node test wrapper that asserts bad fixtures fail (exit 1) and good fixtures pass (exit 0) - `public/app.js` — `escapeHtml` docstring marked CANONICAL with links to the enforcing gate - `.github/workflows/deploy.yml` — invoke `node test-preflight-xss-gate.js` alongside the existing `test-xss-escape-sinks.js` ## TDD red → green | | Commit | Test result | |---|---|---| | **Red** | `test(preflight-xss): RED — fixtures + assertion wrapper for XSS sink gate` | `test-preflight-xss-gate.js` exits 1 — bad fixtures unexpectedly pass because `scripts/check-xss-sinks.sh` is a no-op stub. Genuine assertion failure (not a build error). | | **Green** | `feat(preflight): GREEN — implement XSS-sink check + escapeHtml docstring` | stub replaced with real check; all 5 fixtures behave as expected. | The red commit ships a working stub script so the test runs to completion and fails on an **assertion**, not on a missing-file error. ## Coverage proof — would the gate have caught the originals? - **PR #1537 (10 sinks):** synthetic file from the deleted lines of #1537 → gate flags `n.name` in `innerHTML \`tpl\`` and two `bindPopup(\`…${n.name}\`)` lines. Yes, the gate would have caught these the moment they hit a PR diff. - **Post-#1537 audit:** - **TRACE-1** (`traces.js` `${e.message}` / `${urlHash}` in innerHTML): yes — the `hash`/`urlHash` tokens are allowlisted and the innerHTML template-literal pattern matches. - **OBS-1** (`observer-detail.js` URL fragment + MQTT fields into innerHTML / bindPopup): yes — the `observer`, `text`, `hash` tokens are allowlisted and both sink patterns match. - **ANL-1** (`analytics.js` attribute-mutation roundtrip): yes for `setAttribute('on*', …)` and `setAttribute('href', \`…${interp}…\`)` patterns. (Note: pure innerHTML lines with only `${e.message}` are not node-controlled and are intentionally not flagged.) ## Allowlist (initial 27 identifiers) ``` adv_name name observer observer_name sender from_node channel channel_name model firmware client_version radio iata hopNames nodeLabel obsName n.name o.name obs.name public_key pubkey area_key region_name text body message preview hash urlHash ``` Extend in `~/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt` whenever a new node-controlled field surfaces in an audit — no repo PR required. ## Hard rules respected - No build step, no ESLint plugin, no AST analysis — grep + heuristics + opt-out escape valves - Hard fail (exit 1), not warning-only (exit 2) - PII preflight grep on every commit + this PR body - Same split as the sibling migration-gate PR ## Three-axis merge-readiness - **Mergeable:** yes — branch is clean off `origin/master`, no conflicts - **CI:** will report on push; red commit expected to fail, green commit expected to pass - **Threads:** none open yet (new PR) --------- Co-authored-by: meshcore-bot <bot@local> Co-authored-by: mc-bot <bot@meshcore.local> Co-authored-by: corescope-bot <bot@corescope>
This commit is contained in:
co-authored by
meshcore-bot
mc-bot
corescope-bot
parent
7b43045043
commit
e4a21fc9ab
@@ -128,6 +128,25 @@ jobs:
|
||||
node test-issue-1438-marker-css-vars.js
|
||||
node test-live.js
|
||||
node test-xss-escape-sinks.js
|
||||
node test-preflight-xss-gate.js
|
||||
|
||||
- name: 🛡️ Preflight XSS gate — actual --diff check (PR only)
|
||||
# The fixture self-test above (test-preflight-xss-gate.js) only
|
||||
# asserts the script's behavior against fixtures. It does NOT scan
|
||||
# the PR's own changes. This step closes that gap by running the
|
||||
# gate against added lines in public/**/*.{js,html} on the PR.
|
||||
# Gate is PR-scoped only (per djb finding: merge commits would
|
||||
# slip an opt-out otherwise). Master pushes skip this step.
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PREFLIGHT_PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
|
||||
run: |
|
||||
set -e
|
||||
git fetch origin master --depth=50 2>&1 | tail -3 || true
|
||||
# Materialize PR body to a file for the opt-out parser.
|
||||
printf '%s' "$PR_BODY" > /tmp/pr-body.md
|
||||
PREFLIGHT_PR_BODY=/tmp/pr-body.md bash scripts/check-xss-sinks.sh --diff origin/master
|
||||
|
||||
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
|
||||
run: |
|
||||
|
||||
+10
-1
@@ -796,7 +796,16 @@ window.connectWS = connectWS;
|
||||
double-quoted AND single-quoted attribute contexts (e.g. the
|
||||
data-conflict='${escapeHtml(JSON.stringify(...))}' attr in
|
||||
hop-display.js, where JSON containing a single quote would
|
||||
otherwise break out of the attribute). Fixes #1536. */
|
||||
otherwise break out of the attribute). Fixes #1536.
|
||||
|
||||
CANONICAL ESCAPE for HTML sinks that interpolate node-controlled or
|
||||
MQTT-controlled fields (name, adv_name, observer, sender, channel,
|
||||
pubkey, body, …). Enforced at PR-creation time by:
|
||||
- scripts/check-xss-sinks.sh (local mirror)
|
||||
- ~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh (canonical)
|
||||
- test-preflight-xss-gate.js (CI gate)
|
||||
See also: escapeAttr (public/home.js, public/path-inspector.js) for
|
||||
attribute-only contexts. */
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Node-controlled identifiers — when interpolated into an HTML sink in
|
||||
# public/**/*.{js,html}, they MUST be wrapped in escapeHtml() / escapeAttr()
|
||||
# / safeEsc() (or the diff must justify an opt-out per check-xss-sinks.sh).
|
||||
#
|
||||
# Derived from PR #1537 (10 sinks) + post-#1537 audit (TRACE-1, OBS-1, ANL-1).
|
||||
# One identifier per line. `#` starts a comment. Blank lines OK.
|
||||
#
|
||||
# Extend whenever a new node/MQTT-controlled field surfaces in an audit.
|
||||
|
||||
# advert / identity
|
||||
adv_name
|
||||
name
|
||||
observer
|
||||
observer_name
|
||||
sender
|
||||
from_node
|
||||
channel
|
||||
channel_name
|
||||
|
||||
# device metadata
|
||||
model
|
||||
firmware
|
||||
client_version
|
||||
radio
|
||||
iata
|
||||
|
||||
# hop/route resolution
|
||||
hopNames
|
||||
nodeLabel
|
||||
obsName
|
||||
n.name
|
||||
o.name
|
||||
obs.name
|
||||
|
||||
# keys / regions
|
||||
public_key
|
||||
pubkey
|
||||
area_key
|
||||
region_name
|
||||
|
||||
# message payloads
|
||||
text
|
||||
body
|
||||
message
|
||||
preview
|
||||
|
||||
# routing hash from URL fragment
|
||||
hash
|
||||
urlHash
|
||||
|
||||
# additional node/MQTT-controlled fields (PR #1543 round-2 additions —
|
||||
# cross-referenced with cmd/ingestor/main.go extractObserverMeta + MQTT
|
||||
# topic/origin/target/payload propagation paths).
|
||||
firmware_version
|
||||
clientVersion
|
||||
payload
|
||||
target
|
||||
origin
|
||||
topic
|
||||
display_name
|
||||
displayName
|
||||
nodeName
|
||||
alias
|
||||
nickname
|
||||
Executable
+441
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-xss-sinks.sh — local mirror of the canonical pr-preflight gate at
|
||||
# ~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh.
|
||||
#
|
||||
# THREAT MODEL: This gate targets HONEST AUTHORS who forget to escape a
|
||||
# node-controlled field, NOT hostile authors trying to evade the gate.
|
||||
# Known coverage gaps that an actively-malicious author could exploit
|
||||
# (intentionally — out of scope, callable from a human review pass):
|
||||
# - bracket notation: el['innerHTML'] = nodeName
|
||||
# - aliased writes: const sink = el.innerHTML.bind(el); sink(...)
|
||||
# - deferred sink assignment via a helper indirection
|
||||
# - DOMPurify-bypass payloads inside an otherwise-escaped expression
|
||||
# We accept these as residual risk in exchange for a regex-only gate that
|
||||
# runs in <5s on every PR and surfaces actionable file:line evidence.
|
||||
#
|
||||
# Two modes:
|
||||
# $0 --file <path> Scan a single file. Exit 1 if any flagged sink
|
||||
# interpolates a node-controlled identifier
|
||||
# without escapeHtml/escapeAttr/safeEsc/esc and is
|
||||
# not covered by a same-PR DOM-grep test (passed
|
||||
# via $PREFLIGHT_TEST_FILES, colon-separated)
|
||||
# or a PR-body opt-out matching:
|
||||
# PREFLIGHT-XSS-OPTOUT: <file>:<line> reason="<≥40ch>"
|
||||
# AND the PR carries the `xss-optout` label (passed
|
||||
# via $PREFLIGHT_PR_LABELS, space/comma-separated).
|
||||
# $0 --diff [BASE] Walk git diff $BASE...HEAD for public/**/*.{js,html}
|
||||
# and apply the same rules to added lines only.
|
||||
# BASE defaults to origin/master.
|
||||
#
|
||||
# The canonical pr-preflight gate (skill-side) consumes the same allowlist
|
||||
# format documented inline below.
|
||||
#
|
||||
# Allowlist resolution (first hit wins):
|
||||
# $XSS_ALLOWLIST (explicit override)
|
||||
# ~/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt
|
||||
# scripts/check-xss-sinks.allowlist.txt (repo-local fallback)
|
||||
# built-in default below (minimum viable set)
|
||||
|
||||
set -u
|
||||
|
||||
# Default allowlist — kept in lockstep with the skill-side
|
||||
# data/xss-node-controlled-fields.txt. Add new node/MQTT-controlled
|
||||
# fields to BOTH files.
|
||||
DEFAULT_ALLOW='adv_name name observer observer_name sender from_node channel channel_name model firmware firmware_version client_version clientVersion radio iata hopNames nodeLabel obsName n.name o.name obs.name public_key pubkey area_key region_name text body message preview hash urlHash payload target origin topic display_name displayName nodeName alias nickname'
|
||||
|
||||
resolve_allowlist() {
|
||||
local candidates=(
|
||||
"${XSS_ALLOWLIST:-}"
|
||||
"$HOME/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt"
|
||||
"$(git rev-parse --show-toplevel 2>/dev/null)/scripts/check-xss-sinks.allowlist.txt"
|
||||
)
|
||||
for c in "${candidates[@]}"; do
|
||||
[ -n "$c" ] && [ -f "$c" ] && { echo "$c"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ALLOW_FILE="$(resolve_allowlist || true)"
|
||||
if [ -n "$ALLOW_FILE" ]; then
|
||||
ALLOW_TOKENS=$(grep -vE '^\s*(#|$)' "$ALLOW_FILE" | tr '\n' ' ')
|
||||
else
|
||||
ALLOW_TOKENS="$DEFAULT_ALLOW"
|
||||
fi
|
||||
|
||||
# Python core — does the per-line sink detection, comment/string strip
|
||||
# (preserving short identifier-like string contents and template-literal
|
||||
# ${...} interpolations), per-interpolation escape-helper audit, and
|
||||
# exception-property strip. Called ONCE per file for performance.
|
||||
#
|
||||
# Inputs (env):
|
||||
# XSS_ALLOW_TOKENS space-separated identifier allowlist
|
||||
# XSS_FILE path of file being scanned (for output)
|
||||
# XSS_LINE_OFFSET added to lineno in output (for --diff mode)
|
||||
# XSS_TEST_FILES colon-separated list of same-PR test files
|
||||
# that may carry coverage markers
|
||||
# XSS_PR_BODY path to PR body file (for opt-out)
|
||||
# XSS_PR_LABELS space/comma-separated PR labels (must include
|
||||
# xss-optout for opt-out to apply)
|
||||
# XSS_INPUT_LINES '1' = read tab-separated (lineno\tcontent)
|
||||
# from stdin (diff mode); else read whole file
|
||||
#
|
||||
# Exit 0 = no findings; exit 1 = one or more findings.
|
||||
PY_CORE=$(cat <<'PYEOF'
|
||||
import os, re, sys
|
||||
|
||||
ALLOW_TOKENS = os.environ.get("XSS_ALLOW_TOKENS", "").split()
|
||||
FILE = os.environ.get("XSS_FILE", "<stdin>")
|
||||
LINE_OFFSET = int(os.environ.get("XSS_LINE_OFFSET", "0"))
|
||||
TEST_FILES = [t for t in os.environ.get("XSS_TEST_FILES", "").split(":") if t]
|
||||
PR_BODY = os.environ.get("XSS_PR_BODY", "")
|
||||
PR_LABELS = re.split(r"[\s,]+", os.environ.get("XSS_PR_LABELS", "").strip())
|
||||
INPUT_LINES_MODE = os.environ.get("XSS_INPUT_LINES", "") == "1"
|
||||
|
||||
# Build allowlist word-boundary regex.
|
||||
allow_alt = "|".join(re.escape(t) for t in ALLOW_TOKENS if t)
|
||||
ALLOW_RE = re.compile(rf"(?:^|[^A-Za-z0-9_$])({allow_alt})(?:[^A-Za-z0-9_]|$)") if allow_alt else None
|
||||
|
||||
# Sink patterns — each detects a sink and returns (label, rhs).
|
||||
# For call-form sinks we use a paren-balanced extractor so the RHS
|
||||
# captures the full argument list, including helper calls with nested
|
||||
# parens like escapeHtml(n.name || x.slice(0, 12)).
|
||||
ASSIGN_SINK_RE = re.compile(r"\.(innerHTML|outerHTML|srcdoc)\s*\+?=\s*([^;]*\S)")
|
||||
|
||||
def extract_call_args(line, call_re):
|
||||
"""Find call_re match, then balance parens from the '(' to capture
|
||||
the full argument list. Returns (label_match, args_string) or None."""
|
||||
m = call_re.search(line)
|
||||
if not m:
|
||||
return None
|
||||
# The match must end at or just before the '(' — locate the next '('.
|
||||
paren_start = line.find("(", m.end() - 1)
|
||||
if paren_start < 0:
|
||||
return None
|
||||
depth = 0
|
||||
end = paren_start
|
||||
while end < len(line):
|
||||
c = line[end]
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
end += 1
|
||||
args = line[paren_start+1:end]
|
||||
return (m, args)
|
||||
|
||||
# Patterns that fire after a paren-balanced extraction.
|
||||
CALL_SINK_PATTERNS = [
|
||||
# (regex matching up to the '(', label-builder taking (m, args))
|
||||
(re.compile(r"insertAdjacentHTML\s*\("),
|
||||
lambda m, args: ("insertAdjacentHTML(<rhs>)", _drop_first_arg(args))),
|
||||
(re.compile(r"\.(bindPopup|bindTooltip)\s*\("),
|
||||
lambda m, args: (f".{m.group(1)}(<rhs>)", args)),
|
||||
(re.compile(r"document\.(write|writeln)\s*\("),
|
||||
lambda m, args: (f"document.{m.group(1)}(<rhs>)", args)),
|
||||
(re.compile(r"\.setHTMLUnsafe\s*\("),
|
||||
lambda m, args: (".setHTMLUnsafe(<rhs>)", args)),
|
||||
(re.compile(r"\.createContextualFragment\s*\("),
|
||||
lambda m, args: (".createContextualFragment(<rhs>)", args)),
|
||||
(re.compile(r"\.setAttribute\s*\("),
|
||||
lambda m, args: _setattr_sink(args)),
|
||||
]
|
||||
|
||||
def _drop_first_arg(args):
|
||||
# Find top-level comma, return tail.
|
||||
depth = 0
|
||||
for i, c in enumerate(args):
|
||||
if c == "(": depth += 1
|
||||
elif c == ")": depth -= 1
|
||||
elif c == "," and depth == 0:
|
||||
return args[i+1:]
|
||||
return ""
|
||||
|
||||
def _setattr_sink(args):
|
||||
# 1st arg = attribute name (quoted). 2nd onwards = RHS.
|
||||
am = re.match(r"\s*['\"]([A-Za-z][A-Za-z0-9_-]*)['\"]\s*,", args)
|
||||
if not am:
|
||||
return None
|
||||
attr = am.group(1)
|
||||
rest = args[am.end():]
|
||||
if re.fullmatch(r"on[a-z]+", attr):
|
||||
return (f"setAttribute('{attr}', <rhs>)", rest)
|
||||
if attr in ("href", "src", "action", "formaction"):
|
||||
return (f"setAttribute('{attr}', <rhs>)", rest)
|
||||
return None
|
||||
|
||||
def sink_match(line):
|
||||
# Assignment-form sinks first.
|
||||
am = ASSIGN_SINK_RE.search(line)
|
||||
if am:
|
||||
return (f".{am.group(1)}=<rhs>", am.group(2))
|
||||
# Call-form sinks with paren balancing.
|
||||
for call_re, build in CALL_SINK_PATTERNS:
|
||||
res = extract_call_args(line, call_re)
|
||||
if not res:
|
||||
continue
|
||||
m, args = res
|
||||
built = build(m, args)
|
||||
if built:
|
||||
return built
|
||||
return None
|
||||
|
||||
# Comment/string strip — preserves short identifier-like string contents
|
||||
# (so setAttribute('href', ...) keeps its 'href' marker) and template
|
||||
# literal ${...} interpolations (so the RHS audit can still see node IDs).
|
||||
def strip(line):
|
||||
line = re.sub(r"/\*.*?\*/", "", line)
|
||||
line = re.sub(r"//[^\n]*", "", line)
|
||||
def short_str(m, q):
|
||||
body = m.group(1)
|
||||
if len(body) <= 32 and re.fullmatch(r"[A-Za-z0-9_:\-/.]+", body):
|
||||
return q + body + q
|
||||
return q + q
|
||||
line = re.sub(r"\"((?:[^\"\\]|\\.)*)\"", lambda m: short_str(m, '"'), line)
|
||||
line = re.sub(r"'((?:[^'\\]|\\.)*)'", lambda m: short_str(m, "'"), line)
|
||||
def tpl(m):
|
||||
body = m.group(1)
|
||||
parts = re.findall(r"\$\{[^}]*\}", body)
|
||||
return "`" + "".join(parts) + "`"
|
||||
line = re.sub(r"`((?:[^`\\]|\\.)*)`", tpl, line)
|
||||
return line
|
||||
|
||||
# Strip exception-property accesses (e.message, error.cause.stack,
|
||||
# caughtError.message, myErr.cause.message etc.) — NOT node-controlled.
|
||||
EXC_RE = re.compile(
|
||||
r"\b(?:"
|
||||
r"(?:e|err|ex|exc|exception|error)"
|
||||
r"|(?:[A-Za-z_][A-Za-z0-9_]*[Ee]rr(?:or)?)"
|
||||
r")(?:\.cause)?\.(?:message|stack|name|code|cause)\b"
|
||||
)
|
||||
|
||||
# Peel escape-helper wrappers from a candidate. We balance parens so
|
||||
# `escapeHtml(n.name || x.slice(0, 12))` is fully consumed (not truncated
|
||||
# at the inner `(` like a naïve [^()]* would do).
|
||||
HELPER_NAMES = ("escapeHtml", "escapeAttr", "safeEsc", "esc")
|
||||
def peel_helpers(s):
|
||||
for _ in range(4):
|
||||
new_parts = []
|
||||
i = 0
|
||||
changed = False
|
||||
while i < len(s):
|
||||
matched = False
|
||||
for name in HELPER_NAMES:
|
||||
if s.startswith(name + "(", i) and (i == 0 or not (s[i-1].isalnum() or s[i-1] == "_" or s[i-1] == "$")):
|
||||
# Balance parens from i+len(name).
|
||||
j = i + len(name)
|
||||
depth = 0
|
||||
while j < len(s):
|
||||
c = s[j]
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
j += 1
|
||||
break
|
||||
j += 1
|
||||
if depth == 0:
|
||||
# Consumed wrapper — emit nothing.
|
||||
i = j
|
||||
matched = True
|
||||
changed = True
|
||||
break
|
||||
if not matched:
|
||||
new_parts.append(s[i])
|
||||
i += 1
|
||||
s = "".join(new_parts)
|
||||
if not changed:
|
||||
break
|
||||
return s
|
||||
|
||||
# Extract candidates from a sink's RHS:
|
||||
# 1. Each ${...} interpolation (audited INDEPENDENTLY)
|
||||
# 2. Each `+ IDENT[.prop]*` concat fragment
|
||||
# 3. If no interp/concat, the entire RHS (bare-ident form)
|
||||
def audit_rhs(rhs):
|
||||
interps = re.findall(r"\$\{([^}]*)\}", rhs)
|
||||
concats = re.findall(r"\+\s*([A-Za-z_$][A-Za-z0-9_$.]*)", rhs)
|
||||
candidates = list(interps) + list(concats)
|
||||
if not candidates:
|
||||
candidates = [rhs]
|
||||
for cand in candidates:
|
||||
stripped = EXC_RE.sub("", cand)
|
||||
stripped = peel_helpers(stripped)
|
||||
if not ALLOW_RE:
|
||||
continue
|
||||
m = ALLOW_RE.search(stripped)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
# Sham-test defense: a test file counts as coverage only if it contains
|
||||
# the sink-file basename AND at least one audit marker OUTSIDE comments.
|
||||
# (String literals OK — real DOM-grep tests put the payload in a string.)
|
||||
TEST_MARKER_RE = re.compile(r"(' onfocus=|onerror=alert)")
|
||||
def strip_test_comments(src):
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
src = re.sub(r"//[^\n]*", "", src)
|
||||
return src
|
||||
|
||||
def test_covers(basename):
|
||||
for tf in TEST_FILES:
|
||||
if not os.path.isfile(tf):
|
||||
continue
|
||||
try:
|
||||
with open(tf, encoding="utf-8", errors="replace") as f:
|
||||
src = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
if basename not in src:
|
||||
continue
|
||||
if TEST_MARKER_RE.search(strip_test_comments(src)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def body_optout(file, lineno):
|
||||
if not PR_BODY or not os.path.isfile(PR_BODY):
|
||||
return False
|
||||
if "xss-optout" not in PR_LABELS:
|
||||
return False
|
||||
try:
|
||||
with open(PR_BODY, encoding="utf-8", errors="replace") as f:
|
||||
body = f.read()
|
||||
except Exception:
|
||||
return False
|
||||
pat = rf'PREFLIGHT-XSS-OPTOUT:\s*{re.escape(file)}:{lineno}\s+reason="([^"]*)"'
|
||||
m = re.search(pat, body)
|
||||
if not m:
|
||||
return False
|
||||
reason = m.group(1)
|
||||
if len(reason) < 40:
|
||||
sys.stderr.write(f"::warning::PREFLIGHT-XSS-OPTOUT at {file}:{lineno} "
|
||||
f"rejected — reason has {len(reason)} chars, need ≥40\n")
|
||||
return False
|
||||
sys.stderr.write(f"::warning::PREFLIGHT-XSS-OPTOUT accepted at {file}:{lineno} "
|
||||
f"(reason: {reason[:80]}…)\n")
|
||||
return True
|
||||
|
||||
def emit_finding(file, lineno, token, sink):
|
||||
if test_covers(os.path.basename(file)):
|
||||
print(f"ℹ️ {file}:{lineno}: flagged token '{token}' in {sink} — accepted via same-PR DOM-grep test")
|
||||
return False
|
||||
if body_optout(file, lineno):
|
||||
print(f"ℹ️ {file}:{lineno}: flagged token '{token}' in {sink} — author opt-out in PR body (xss-optout label + ≥40ch reason)")
|
||||
return False
|
||||
print(f"❌ {file}:{lineno}: flagged: {token} (sink: {sink})")
|
||||
print(f" fix: wrap with escapeHtml(...) / escapeAttr(...) — or add a DOM-grep test in test*.js asserting the payload renders inert — or add 'PREFLIGHT-XSS-OPTOUT: {file}:{lineno} reason=\"...(≥40 chars)...\"' to the PR body AND apply the xss-optout label.")
|
||||
return True
|
||||
|
||||
def scan_lines(lines_with_no):
|
||||
fail = False
|
||||
for lineno, content in lines_with_no:
|
||||
if not content.strip():
|
||||
continue
|
||||
stripped = strip(content)
|
||||
sm = sink_match(stripped)
|
||||
if not sm:
|
||||
continue
|
||||
sink, rhs = sm
|
||||
token = audit_rhs(rhs)
|
||||
if not token:
|
||||
continue
|
||||
if emit_finding(FILE, lineno + LINE_OFFSET, token, sink):
|
||||
fail = True
|
||||
return 1 if fail else 0
|
||||
|
||||
if INPUT_LINES_MODE:
|
||||
pairs = []
|
||||
for raw in sys.stdin:
|
||||
raw = raw.rstrip("\n")
|
||||
if "\t" not in raw:
|
||||
continue
|
||||
ln, content = raw.split("\t", 1)
|
||||
try:
|
||||
pairs.append((int(ln), content))
|
||||
except ValueError:
|
||||
continue
|
||||
sys.exit(scan_lines(pairs))
|
||||
else:
|
||||
# Whole-file mode.
|
||||
if not os.path.isfile(FILE):
|
||||
sys.stderr.write(f"no such file: {FILE}\n")
|
||||
sys.exit(2)
|
||||
with open(FILE, encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
pairs = [(i + 1, line) for i, line in enumerate(text.split("\n"))]
|
||||
sys.exit(scan_lines(pairs))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
scan_file() {
|
||||
local target="$1" offset="${2:-0}"
|
||||
XSS_ALLOW_TOKENS="$ALLOW_TOKENS" \
|
||||
XSS_FILE="$target" \
|
||||
XSS_LINE_OFFSET="$offset" \
|
||||
XSS_TEST_FILES="${PREFLIGHT_TEST_FILES:-}" \
|
||||
XSS_PR_BODY="${PREFLIGHT_PR_BODY:-}" \
|
||||
XSS_PR_LABELS="${PREFLIGHT_PR_LABELS:-}" \
|
||||
XSS_INPUT_LINES="" \
|
||||
python3 -c "$PY_CORE"
|
||||
}
|
||||
|
||||
scan_diff() {
|
||||
local base="$1"
|
||||
local files
|
||||
files=$(git diff "$base"...HEAD --name-only --diff-filter=AM \
|
||||
| grep -E '^public/.*\.(js|html)$' || true)
|
||||
[ -z "$files" ] && { echo "check-xss-sinks: no public/**/*.{js,html} changes to scan"; return 0; }
|
||||
local rc=0
|
||||
local file
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
local diff_lines
|
||||
diff_lines=$(git diff --unified=0 "$base"...HEAD -- "$file" | awk '
|
||||
/^@@/ {
|
||||
match($0, /\+[0-9]+/)
|
||||
if (RSTART) { cur = substr($0, RSTART+1, RLENGTH-1) + 0 } else { cur = 0 }
|
||||
next
|
||||
}
|
||||
/^\+\+\+/ { next }
|
||||
/^\+/ { print cur "\t" substr($0, 2); cur++; next }
|
||||
/^-/ { next }
|
||||
/^ / { cur++ }
|
||||
')
|
||||
[ -z "$diff_lines" ] && continue
|
||||
local sub_rc=0
|
||||
printf '%s\n' "$diff_lines" | \
|
||||
XSS_ALLOW_TOKENS="$ALLOW_TOKENS" \
|
||||
XSS_FILE="$file" \
|
||||
XSS_LINE_OFFSET=0 \
|
||||
XSS_TEST_FILES="${PREFLIGHT_TEST_FILES:-}" \
|
||||
XSS_PR_BODY="${PREFLIGHT_PR_BODY:-}" \
|
||||
XSS_PR_LABELS="${PREFLIGHT_PR_LABELS:-}" \
|
||||
XSS_INPUT_LINES=1 \
|
||||
python3 -c "$PY_CORE" || sub_rc=$?
|
||||
[ "$sub_rc" -ne 0 ] && rc=1
|
||||
done <<<"$files"
|
||||
return $rc
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
shift || true
|
||||
case "$mode" in
|
||||
--file)
|
||||
target="${1:-}"
|
||||
[ -z "$target" ] && { echo "usage: $0 --file <path>" >&2; exit 2; }
|
||||
[ -f "$target" ] || { echo "no such file: $target" >&2; exit 2; }
|
||||
scan_file "$target" 0
|
||||
exit $?
|
||||
;;
|
||||
--diff)
|
||||
base="${1:-${BASE:-origin/master}}"
|
||||
scan_diff "$base"
|
||||
exit $?
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 --file <path> | $0 --diff [BASE]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
// test-preflight-xss-gate.js — exercises scripts/check-xss-sinks.sh against
|
||||
// the testdata/preflight-xss fixtures. Asserts the bad fixtures HARD-FAIL
|
||||
// (exit 1) and the good fixtures pass (exit 0).
|
||||
//
|
||||
// This is the repo-side validation of the canonical pr-preflight gate
|
||||
// documented at ~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh.
|
||||
// The skill-side script enforces the gate at PR-creation time; this test
|
||||
// guards against regressions in the local mirror at scripts/check-xss-sinks.sh.
|
||||
//
|
||||
// Each fixture line is a behavioral assertion:
|
||||
// bad-* MUST fail — proves the gate catches the unescaped sink class.
|
||||
// good-* MUST pass — proves the gate doesn't false-positive on escaped
|
||||
// or test-covered sinks.
|
||||
//
|
||||
// Exit 1 on any assertion failure.
|
||||
|
||||
'use strict';
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'scripts/check-xss-sinks.sh');
|
||||
const FIXTURE_DIR = path.resolve(__dirname, 'testdata/preflight-xss');
|
||||
|
||||
if (!fs.existsSync(SCRIPT)) {
|
||||
console.error(`FAIL: ${SCRIPT} missing`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(FIXTURE_DIR)) {
|
||||
console.error(`FAIL: ${FIXTURE_DIR} missing`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Each case may specify which test file(s) to expose via PREFLIGHT_TEST_FILES.
|
||||
const cases = [
|
||||
// bad fixtures: gate MUST flag (exit 1)
|
||||
{ file: 'bad-1-template-literal.js', expect: 1, label: 'innerHTML template literal with ${name}' },
|
||||
{ file: 'bad-2-setAttribute-href.js', expect: 1, label: "setAttribute('href', `…${hash}…`)" },
|
||||
{ file: 'bad-3-bindPopup.js', expect: 1, label: 'Leaflet bindPopup(`…${observer}…`)' },
|
||||
{ file: 'bad-4-bare-ident.js', expect: 1, label: 'innerHTML = name (bare ident, no quote/backtick)' },
|
||||
{ file: 'bad-5-string-concat.js', expect: 1, label: 'innerHTML = name + "<b>" (concat)' },
|
||||
{ file: 'bad-6-bindPopup-concat.js', expect: 1, label: 'bindPopup("Name: " + observer) (concat)' },
|
||||
{ file: 'bad-7-outerHTML.js', expect: 1, label: 'outerHTML sink' },
|
||||
{ file: 'bad-8-document-write.js', expect: 1, label: 'document.write template literal' },
|
||||
{ file: 'bad-9-shamtest-fixture.js', expect: 1, label: 'sham test (markers only in comments)',
|
||||
tests: ['sham-test-fixture-9.js'] },
|
||||
{ file: 'bad-10-line-level-escape-bypass.js', expect: 1,
|
||||
label: '${escapeHtml(role)} ${name} — one wrapped, one raw' },
|
||||
{ file: 'bad-11-comment-rubber-stamp.js', expect: 1,
|
||||
label: 'escapeHtml mentioned only in // comment' },
|
||||
{ file: 'bad-12-setattr-concat.js', expect: 1,
|
||||
label: 'setAttribute("href", "javascript:" + payload) (concat, no $)' },
|
||||
// good fixtures: gate MUST pass (exit 0)
|
||||
{ file: 'good-1-escaped.js', expect: 0, label: 'escapeHtml(${name}) wrapper' },
|
||||
{ file: 'good-2-tested.js', expect: 0, label: 'unescaped but DOM-grep-tested in same PR',
|
||||
tests: ['test-good-2.js'] },
|
||||
{ file: 'good-3-catch-block-error.js', expect: 0,
|
||||
label: 'catch (e) { ${e.message} } — exception property' },
|
||||
{ file: 'good-3b-chained-cause.js', expect: 0,
|
||||
label: '${error.cause.message} / ${parseError.message} chained error props' },
|
||||
{ file: 'good-4-tested.js', expect: 0,
|
||||
label: 'unescaped sink, REAL test (markers in executable code)',
|
||||
tests: ['test-good-4.js'] },
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const c of cases) {
|
||||
const target = path.join(FIXTURE_DIR, c.file);
|
||||
if (!fs.existsSync(target)) {
|
||||
console.error(`FAIL: fixture missing: ${target}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const tests = (c.tests || []).map(t => path.join(FIXTURE_DIR, t));
|
||||
const env = Object.assign({}, process.env);
|
||||
if (tests.length > 0) {
|
||||
env.PREFLIGHT_TEST_FILES = tests.join(':');
|
||||
} else {
|
||||
delete env.PREFLIGHT_TEST_FILES;
|
||||
}
|
||||
const res = spawnSync('bash', [SCRIPT, '--file', target], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const got = res.status;
|
||||
if (got !== c.expect) {
|
||||
console.error(`FAIL: ${c.file} (${c.label}) — expected exit ${c.expect}, got ${got}`);
|
||||
if (res.stdout) console.error(' stdout:', res.stdout.trim());
|
||||
if (res.stderr) console.error(' stderr:', res.stderr.trim());
|
||||
failed++;
|
||||
} else {
|
||||
console.log(`PASS: ${c.file} — ${c.label} (exit ${got})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} assertion(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll preflight-xss-gate assertions passed.');
|
||||
@@ -0,0 +1,7 @@
|
||||
// bad-1-template-literal.js — XSS fixture for check-xss-sinks.
|
||||
// Unescaped ${name} (node-controlled) interpolated into innerHTML.
|
||||
// EXPECTED: flagged by check-xss-sinks.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div class="node">${name}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// bad-10-line-level-escape-bypass.js — one interp escaped, another raw.
|
||||
// Old line-level has_escape rubber-stamps because escapeHtml( appears on
|
||||
// the line. New per-interp audit must flag the raw ${name}.
|
||||
/* eslint-disable */
|
||||
function escapeHtml(s) { return String(s); }
|
||||
function render(el, role, name) {
|
||||
el.innerHTML = `<div>${escapeHtml(role)} ${name}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// bad-11-comment-rubber-stamp.js — escapeHtml mentioned only in a comment.
|
||||
// Old has_escape line-level rubber-stamps; new strips comments first.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div>${name}</div>`; // TODO: escapeHtml(name)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// bad-12-setattr-concat.js — setAttribute href with string concat,
|
||||
// no `$` in the value. Old regex requires `$`; concat slips.
|
||||
/* eslint-disable */
|
||||
function render(a, payload) {
|
||||
a.setAttribute('href', 'javascript:' + payload);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// bad-2-setAttribute-href.js — XSS fixture for check-xss-sinks.
|
||||
// setAttribute('href', <interpolation>) accepts javascript: URIs.
|
||||
// EXPECTED: flagged by check-xss-sinks.
|
||||
/* eslint-disable */
|
||||
function attach(a, hash) {
|
||||
a.setAttribute('href', `#/packets/${hash}`);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// bad-3-bindPopup.js — XSS fixture for check-xss-sinks.
|
||||
// Leaflet bindPopup with raw ${observer} interpolation.
|
||||
// EXPECTED: flagged by check-xss-sinks.
|
||||
/* eslint-disable */
|
||||
function bindPopupForMarker(marker, observer) {
|
||||
marker.bindPopup(`<b>${observer}</b>`);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// bad-4-bare-ident.js — innerHTML = bare identifier, NO quote/backtick.
|
||||
// Old script's quote-required regex misses this. New script must flag.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = name;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// bad-5-string-concat.js — innerHTML = ident + '<b>'. Old script
|
||||
// only matches when RHS begins with quote/backtick; concat slips.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = name + '<b>extra</b>';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// bad-6-bindPopup-concat.js — Leaflet bindPopup with string-concat
|
||||
// node-controlled name. Old script only catches backtick form.
|
||||
/* eslint-disable */
|
||||
function render(marker, observer) {
|
||||
marker.bindPopup('Name: ' + observer);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// bad-7-outerHTML.js — outerHTML sink, not covered by old script.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.outerHTML = `<div>${name}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// bad-8-document-write.js — document.write sink.
|
||||
/* eslint-disable */
|
||||
function render(name) {
|
||||
document.write(`<h1>${name}</h1>`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// bad-9-sham-test.js — unescaped sink relying on a sham companion test
|
||||
// that only mentions the markers in COMMENTS.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div>${name}</div>`;
|
||||
}
|
||||
module.exports = { render };
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// good-1-escaped.js — passes check-xss-sinks: escapeHtml wraps the field.
|
||||
/* eslint-disable */
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div class="node">${escapeHtml(name)}</div>`;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// good-2-tested.js — interpolates a node-controlled field unescaped,
|
||||
// but the SAME PR adds test-good-2.js which DOM-greps the audit payload
|
||||
// against this file. check-xss-sinks must therefore accept this sink.
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div class="node">${name}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// good-3-catch-block-error.js — passes check-xss-sinks: exception
|
||||
// .message access inside catch is NOT node-controlled.
|
||||
// EXPECTED: clean.
|
||||
/* eslint-disable */
|
||||
function run(el) {
|
||||
try {
|
||||
JSON.parse('not json');
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="err">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// good-3b-chained-cause.js — chained error.cause.message must not flag.
|
||||
/* eslint-disable */
|
||||
function run(el, error) {
|
||||
el.innerHTML = `<div>${error.cause.message}</div>`;
|
||||
}
|
||||
function run2(el, parseError) {
|
||||
el.innerHTML = `<div>${parseError.message}</div>`;
|
||||
}
|
||||
function run3(el, myErr) {
|
||||
el.innerHTML = `<div>${myErr.cause.stack}</div>`;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// good-4-tested.js — unescaped sink, covered by REAL render-and-grep test
|
||||
// (markers in executable code, not just comments).
|
||||
/* eslint-disable */
|
||||
function render(el, name) {
|
||||
el.innerHTML = `<div>${name}</div>`;
|
||||
}
|
||||
module.exports = { render };
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// sham-test-fixture-9.js — SHAM coverage test. Mentions bad-9-shamtest-fixture.js basename
|
||||
// and the audit markers ONLY INSIDE COMMENTS. Old test_covers() rubber-stamps
|
||||
// this. New test_covers() must reject it because the markers don't appear
|
||||
// in executable code.
|
||||
//
|
||||
// References: bad-9-shamtest-fixture.js
|
||||
// Markers (in comments only):
|
||||
// ' onfocus=alert(1)
|
||||
// onerror=alert(1)
|
||||
'use strict';
|
||||
console.log('sham test — does nothing');
|
||||
process.exit(0);
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// test-good-2.js — DOM-grep coverage test for testdata/preflight-xss/good-2-tested.js
|
||||
// Demonstrates the (b) opt-out clause of check-xss-sinks: a same-PR test
|
||||
// asserting the audit payload renders inert satisfies the gate without
|
||||
// requiring escapeHtml() at the sink.
|
||||
//
|
||||
// References file basename "good-2-tested.js" and BOTH audit payload markers
|
||||
// (' onfocus= and onerror=alert) so check-xss-sinks' test_covers() matches.
|
||||
'use strict';
|
||||
const { JSDOM } = (() => {
|
||||
try { return require('jsdom'); }
|
||||
catch { return { JSDOM: null }; }
|
||||
})();
|
||||
|
||||
if (!JSDOM) {
|
||||
console.log('test-good-2: jsdom not available, skipping (marker strings still grep-visible)');
|
||||
// Markers are still present in this source file for check-xss-sinks:
|
||||
// ' onfocus=alert(1)
|
||||
// onerror=alert(1)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { render } = require('./testdata/preflight-xss/good-2-tested.js');
|
||||
const dom = new JSDOM('<!doctype html><div id="root"></div>');
|
||||
const el = dom.window.document.getElementById('root');
|
||||
// Payload taken from the post-#1537 XSS audit:
|
||||
// ' onfocus=alert(1) autofocus '
|
||||
// "<img src=x onerror=alert(1)>"
|
||||
const payload = "' onfocus=alert(1) autofocus 'onerror=alert(1)";
|
||||
render(el, payload);
|
||||
if (el.querySelector('img[onerror], [onfocus]')) {
|
||||
console.error('FAIL: payload rendered as live attributes');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('PASS: payload rendered inert');
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// test-good-4.js — REAL DOM-grep test for good-4-tested.js. Markers appear
|
||||
// in EXECUTABLE code (string literals used as test payloads), not just
|
||||
// in comments — the post-hardening test_covers() must accept this.
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, 'good-4-tested.js'), 'utf8');
|
||||
// The payload below is fed to render() in a jsdom run when jsdom is present.
|
||||
// Even without jsdom, the markers below are present as live string values
|
||||
// (NOT comments), so the gate's test_covers() considers them coverage.
|
||||
const payload1 = "' onfocus=alert(1) autofocus '";
|
||||
const payload2 = '<img src=x onerror=alert(1)>';
|
||||
if (!src.includes('innerHTML')) {
|
||||
console.error('FAIL: source missing innerHTML sink');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('PASS markers live:', payload1.length + payload2.length);
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user