Independent education · Sources and limitations documented
Portfolio / Portfolio records

Portfolio record

Source snapshot · 5 November 2026

tools/site/sanitize-html.py

import json
import re
import sys
from html import escape
from html.parser import HTMLParser
from urllib.parse import urlsplit

ALLOWED={'a', 'abbr', 'article', 'aside', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'dd', 'del', 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'kbd', 'li', 'main', 'mark', 'ol', 'p', 'pre', 's', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'}
VOID={'br','hr','img','col'}
BLOCK={'script','style','iframe','object','embed','template','svg','math','textarea','noscript'}
ATTRS={'id','class','title','role','aria-label','aria-hidden','tabindex','open','colspan','rowspan','scope','alt','width','height','href','src','download','target','rel'}
def safe_url(value):
 clean=re.sub(r'[\x00-\x20\x7f]','',value)
 if clean.startswith(('//','\\')):return False
 try:return urlsplit(clean).scheme.lower() in ('','https','mailto')
 except ValueError:return False
def same_site_url(value):
 # Images must load under the site CSP (img-src 'self'): relative URLs only.
 clean=re.sub(r'[\x00-\x20\x7f]','',value)
 if not clean or clean.startswith(('/','\\')):return False
 try:u=urlsplit(clean)
 except ValueError:return False
 return not u.scheme and not u.netloc
class Sanitizer(HTMLParser):
 def __init__(self):super().__init__(convert_charrefs=True);self.out=[];self.block=[]
 def handle_starttag(self,tag,attrs):
  if self.block:
   if tag in BLOCK:self.block.append(tag)
   return
  if tag in BLOCK:self.block.append(tag);return
  if tag not in ALLOWED:return
  if tag=='img':
   a=dict(attrs)
   srcs=[v or '' for k,v in attrs if k=='src']
   # Exactly one same-site src (a duplicate src could smuggle an external first value past dict()).
   if len(srcs)!=1 or not same_site_url(srcs[0]):
    # External or missing image source: keep only the alternative text.
    alt=(a.get('alt') or '').strip()
    if alt:self.out.append('<span class="img-alt">'+escape(alt,quote=False)+'</span>')
    return
  safe=[];seen=set()
  for k,v in attrs:
   # Browsers use the first of duplicate attributes; keep only the first so what we check is what renders.
   if k not in ATTRS or k in seen:continue
   seen.add(k)
   v=v or ''
   if k in ('href','src') and not safe_url(v):continue
   if k=='target' and v!='_blank':continue
   if k=='rel':continue
   safe.append(' '+k+'="'+escape(v,quote=True)+'"')
  if tag=='a':safe.append(' rel="noopener noreferrer"')
  self.out.append('<'+tag+''.join(safe)+'>')
 def handle_startendtag(self,tag,attrs):
  if tag in BLOCK:return
  self.handle_starttag(tag,attrs)
  if tag not in VOID:self.handle_endtag(tag)
 def handle_endtag(self,tag):
  if self.block:
   if tag==self.block[-1]:self.block.pop()
   return
  if tag in ALLOWED and tag not in VOID:self.out.append('</'+tag+'>')
 def handle_data(self,data):
  if not self.block:self.out.append(escape(data,quote=False))
def sanitize(value):
 parser=Sanitizer();parser.feed(value);parser.close()
 if parser.block:raise ValueError('unclosed blocked element <'+parser.block[0]+'>: content after it would be dropped')
 return ''.join(parser.out)
def sanitize_batch(items):
 # Input: JSON array of {"name": str, "html": str}; output: array of sanitized strings.
 if not isinstance(items,list):raise TypeError('batch input must be a JSON array')
 out=[]
 for n,item in enumerate(items):
  name=str(item.get('name','document '+str(n))) if isinstance(item,dict) else 'document '+str(n)
  if not isinstance(item,dict) or not isinstance(item.get('html'),str):raise TypeError(name+': each item needs a string "html" field')
  try:out.append(sanitize(item['html']))
  except ValueError as e:raise ValueError(name+': '+str(e)) from e
 return out
def main(argv):
 if argv[1:]==['--batch']:
  # Read bytes as UTF-8 regardless of the locale (Windows consoles default to cp1252).
  try:result=sanitize_batch(json.loads(sys.stdin.buffer.read().decode('utf-8')))
  except (ValueError,TypeError) as e:
   sys.stderr.write('sanitize-html: '+str(e)+'\n');return 1
  sys.stdout.write(json.dumps(result));return 0
 if argv[1:]:
  sys.stderr.write('usage: sanitize-html.py [--batch]\n');return 2
 sys.stdout.buffer.write(sanitize(sys.stdin.buffer.read().decode('utf-8')).encode('utf-8'));return 0
if __name__=='__main__':sys.exit(main(sys.argv))
Source and scope

Published from CM-2026/tools/site/sanitize-html.py at revision aa26982aed8c. Full source text is retained; HTML presentation does not establish independent clinical review. See authorship, provenance and review records.