Files
learningcircuit--local-deep…/.pre-commit-hooks/check-target-blank-rel.py
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

171 lines
6.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Pre-commit hook to enforce rel="noopener noreferrer" on every
<a target="_blank"> that points at an external (cross-origin) URL.
Without rel="noopener", the opened page can access window.opener and
perform tabnabbing attacks. Without rel="noreferrer", the Referer header
leaks the LDR URL to the destination.
The check is "flag unless proven internal": a new-tab link must carry the
rel unless we can statically prove it stays same-origin. This intentionally
also covers dynamic hrefs — JS ${...} and Jinja {{ ... }} expressions that
are not url_for() — since those are exactly where past regressions lived.
Provably same-origin anchors (href starting with "/", "#", "?", a Jinja
url_for, or a non-HTTP pseudo-scheme such as mailto:) are skipped — there is
no cross-origin tabnabbing risk on those.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# Match <a ...> opening tags, tolerating multi-line attribute lists: the
# negated class [^>] already spans newlines, so a tag whose attributes wrap
# across lines is captured up to its first ">". IGNORECASE also matches
# <A ...>. Known limitation: a ">" inside a quoted attribute value (e.g.
# title="a > b") truncates the match early; our anchors don't contain one.
ANCHOR_RE = re.compile(r"<a\b([^>]*?)>", re.IGNORECASE)
ATTR_RE = re.compile(
r"""([A-Za-z_:][-A-Za-z0-9_:.]*) # attribute name
\s*=\s*
(?: "([^"]*)" | '([^']*)' | (\S+) ) # quoted or bare value
""",
re.VERBOSE,
)
# Jinja url_for() always resolves to a same-origin path, e.g.
# {{ url_for('x') }} or {{- url_for(...) }}.
URL_FOR_RE = re.compile(r"\{\{-?\s*url_for\b", re.IGNORECASE)
# Non-HTTP pseudo-schemes that are not new-tab navigation / tabnabbing vectors.
SAFE_SCHEMES = ("mailto:", "tel:", "sms:", "javascript:", "data:")
def requires_rel(href: str) -> bool:
"""Return True unless the href is provably same-origin / non-navigational.
Deliberately "flag unless proven internal": a new-tab link should carry
rel="noopener noreferrer" unless we can PROVE it stays same-origin. This
covers dynamic links — JS ${...} and Jinja {{ ... }} expressions that are
not url_for() — which an "only flag provably-external" check would miss
(and which is exactly where past regressions lived).
Provably internal (returns False): empty; a same-origin absolute path
"/..." (but not protocol-relative "//host"); a "#fragment" or "?query"; a
Jinja {{ url_for(...) }}; and a non-HTTP pseudo-scheme (mailto:, tel:,
sms:, javascript:, data:). Everything else — http(s)://, //host, a bare
host like "example.com", or an unresolved ${...} / {{ ... }} expression —
returns True so the rel is required.
"""
h = href.strip()
if not h:
return False
# Same-origin absolute path (but NOT protocol-relative //host).
if h.startswith("/") and not h.startswith("//"):
return False
# Fragment or query against the current document.
if h.startswith(("#", "?")):
return False
# Jinja url_for() always resolves to a same-origin path.
if URL_FOR_RE.match(h):
return False
# Non-HTTP pseudo-schemes are not new-tab navigation.
if h.lower().startswith(SAFE_SCHEMES):
return False
# Anything else (http(s)://, //host, bare host, or an unresolved
# ${...} / {{ ... }} expression) cannot be proven same-origin — flag it.
return True
def parse_attrs(attr_blob: str) -> dict[str, str]:
"""Parse anchor attributes into a name -> value dict (lowercased keys)."""
out: dict[str, str] = {}
for m in ATTR_RE.finditer(attr_blob):
name = m.group(1).lower()
value = m.group(2) or m.group(3) or m.group(4) or ""
out[name] = value
return out
def check_file(filepath: Path) -> list[tuple[int, str]]:
"""Return list of (line_number, snippet) violations for filepath."""
try:
text = filepath.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
violations: list[tuple[int, str]] = []
for match in ANCHOR_RE.finditer(text):
attr_blob = match.group(1)
attrs = parse_attrs(attr_blob)
target = attrs.get("target", "").lower()
if target != "_blank":
continue
href = attrs.get("href", "")
if not requires_rel(href):
continue
rel = attrs.get("rel", "").lower()
rel_tokens = set(rel.split())
if "noopener" in rel_tokens and "noreferrer" in rel_tokens:
continue
# Find the line number of the match start
line_num = text.count("\n", 0, match.start()) + 1
snippet = match.group(0).strip().replace("\n", " ")
if len(snippet) > 140:
snippet = snippet[:137] + "..."
violations.append((line_num, snippet))
return violations
def main() -> int:
files = [Path(f) for f in sys.argv[1:]]
skip_parts = {"vendor", "node_modules", ".venv", "venv", "__pycache__"}
all_violations: list[tuple[Path, list[tuple[int, str]]]] = []
for filepath in files:
if any(part in skip_parts for part in filepath.parts):
continue
vs = check_file(filepath)
if vs:
all_violations.append((filepath, vs))
if not all_violations:
return 0
print('\nExternal <a target="_blank"> missing rel="noopener noreferrer":\n')
print(
"Without noopener, the opened page can access window.opener "
"(tabnabbing).\nWithout noreferrer, the Referer header leaks the "
"LDR URL to the destination.\n"
)
for filepath, vs in all_violations:
print(f" {filepath}")
for line_num, snippet in vs:
print(f" line {line_num}: {snippet}")
print()
print(
'Fix: add rel="noopener noreferrer" to each flagged anchor. '
"Provably same-origin\n"
"links (href starts with /, #, ?, a Jinja url_for, or a mailto:/tel: "
"scheme)\nare skipped automatically. For a dynamic href that is "
'genuinely internal,\nprefer a leading "/" so the check can prove it.'
)
return 1
if __name__ == "__main__":
sys.exit(main())