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
171 lines
6.1 KiB
Python
Executable File
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())
|