#!/usr/bin/env python3 """ Pre-commit hook to enforce rel="noopener noreferrer" on every 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 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 # . 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"]*?)>", 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 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())