71366eb92d89bbb2cf87e2a9293ea019b63981a4 braney Fri Aug 21 10:22:02 2026 -0700 htmlSanitize: keep odd characters out of the messages we hand back, refs #38126 An attribute name in HTML can hold almost anything, and the name goes into a message that hubCheck prints to a terminal. Name the attribute only when it reads like a name. diff --git src/lib/htmlSanitize.c src/lib/htmlSanitize.c index 07b85af685a..cf5f1187685 100644 --- src/lib/htmlSanitize.c +++ src/lib/htmlSanitize.c @@ -1,830 +1,842 @@ /* htmlSanitize - reduce a piece of HTML that came from outside to an allowlist of * elements, attributes and style properties. * * An element on the keep list survives with its allowlisted attributes. A short list of * elements that carry nothing a reader needs, script and style and form among them, is * removed together with everything inside. Every other element loses its tag but keeps * its text, which is what lets a whole pasted document come out as the article it was * meant to be. * * The parser here is deliberately forgiving. It never aborts and it always returns * something, however broken the markup it is handed. */ /* Copyright (C) 2026 The Regents of the University of California * See kent/LICENSE or http://genome.ucsc.edu/license/ for licensing information. */ #include "common.h" #include "hash.h" #include "dystring.h" #include "htmlSanitize.h" /* Elements we keep. */ static char *keepElements = "p br hr div span center address blockquote pre " "h1 h2 h3 h4 h5 h6 " "a b strong i em u s strike small big sub sup code tt kbd samp var cite " "abbr acronym dfn mark del ins q wbr font " "ul ol li dl dt dd " "table caption thead tbody tfoot tr th td col colgroup " "img figure figcaption " "section article header footer main aside nav details summary " "iframe"; /* iframe is here but only survives when its source is a video host, see iframeSrcOk. */ /* Elements that go away with everything inside them. */ static char *killElements = "script style noscript template svg math frame frameset " "object embed applet param form input button select option optgroup " "textarea label fieldset legend base meta link title " "audio video source track canvas map area marquee dialog slot portal xml"; /* Elements written without a closing tag. Killing one of these takes no content with it. */ static char *voidElements = "area base br col embed frame hr img input link meta param source track wbr"; /* Killed elements that never showed a reader anything, so there is nothing to tell the * hub author about. */ static char *silentKillElements = "title meta link base param source track xml slot portal template noscript"; /* Elements whose content is text rather than markup. When one of these is never closed we * drop the rest of the input rather than pour its content onto the page as text. */ static char *rawTextElements = "script style textarea title noscript template xml"; /* Attributes allowed, by element. The first row is for every kept element. */ struct attrRule { char *element; /* Element name, or "*" for all of them. */ char *attrs; /* Space separated attribute names. */ }; static struct attrRule attrRules[] = { {"*", "title dir lang style id"}, {"a", "href target rel name"}, {"img", "src alt width height border align hspace vspace"}, {"table", "width border cellpadding cellspacing align bgcolor summary"}, {"td", "colspan rowspan align valign width height nowrap bgcolor scope"}, {"th", "colspan rowspan align valign width height nowrap bgcolor scope"}, {"tr", "align valign bgcolor"}, {"col", "span width align valign"}, {"colgroup", "span width align valign"}, {"ol", "start type reversed"}, {"ul", "type"}, {"li", "type value"}, {"font", "color face size"}, {"hr", "width size align noshade"}, {"p", "align"}, {"div", "align"}, {"span", "align"}, {"h1", "align"}, {"h2", "align"}, {"h3", "align"}, {"h4", "align"}, {"h5", "align"}, {"h6", "align"}, {"caption", "align"}, {"thead", "align valign"}, {"tbody", "align valign"}, {"tfoot", "align valign"}, {"iframe", "src width height frameborder allowfullscreen allow loading"}, }; /* Properties allowed inside a style attribute. */ static char *styleProperties = "text-align text-align-last vertical-align white-space word-break word-wrap overflow-wrap " "padding padding-top padding-bottom padding-left padding-right " "margin margin-top margin-bottom margin-left margin-right " "border border-top border-bottom border-left border-right border-color border-style " "border-width border-radius border-collapse border-spacing " "width height min-width max-width min-height max-height " "color background-color " "font font-size font-weight font-style font-family font-variant " "line-height letter-spacing text-decoration text-transform text-indent " "list-style list-style-type list-style-position " "float clear display overflow overflow-x overflow-y opacity"; /* URL schemes allowed in href and src. A URL with no scheme at all is allowed too. */ static char *urlSchemes = "http https mailto ftp"; /* Hosts an iframe may point at. */ static char *videoHosts = "www.youtube.com youtube.com www.youtube-nocookie.com youtube-nocookie.com youtu.be " "player.vimeo.com vimeo.com"; /* Nesting past this depth is not a document, it is a way to make us emit a huge page. */ #define maxNestDepth 256 static struct hash *keepHash = NULL, *killHash = NULL, *voidHash = NULL, *rawTextHash = NULL; static struct hash *silentKillHash = NULL; static struct hash *attrHash = NULL, *stylePropHash = NULL, *schemeHash = NULL, *videoHostHash = NULL; static struct hash *hashOfWords(char *words, int sizePow2) /* Return a hash holding each space separated word in words. */ { struct hash *hash = hashNew(sizePow2); char *dupe = cloneString(words); char *word, *s = dupe; while ((word = nextWord(&s)) != NULL) hashAdd(hash, word, NULL); freeMem(dupe); return hash; } static void initTables() /* Build the lookup hashes on first use. */ { if (keepHash != NULL) return; killHash = hashOfWords(killElements, 7); silentKillHash = hashOfWords(silentKillElements, 5); voidHash = hashOfWords(voidElements, 6); rawTextHash = hashOfWords(rawTextElements, 5); stylePropHash = hashOfWords(styleProperties, 8); schemeHash = hashOfWords(urlSchemes, 4); videoHostHash = hashOfWords(videoHosts, 4); attrHash = hashNew(9); int i; for (i = 0; i < ArraySize(attrRules); ++i) { char *dupe = cloneString(attrRules[i].attrs); char *attr, *s = dupe; while ((attr = nextWord(&s)) != NULL) { char key[256]; safef(key, sizeof key, "%s.%s", attrRules[i].element, attr); hashAdd(attrHash, key, NULL); } freeMem(dupe); } keepHash = hashOfWords(keepElements, 7); /* last, it is the flag that we are built */ } struct sanitizer /* State of one pass over a piece of HTML. */ { struct dyString *out; /* Sanitized HTML accumulates here. */ struct slName *openStack; /* Elements opened and not yet closed, innermost first. */ int depth; /* Length of openStack. */ struct hash *exhausted; /* Elements we have already looked for a closing tag of * and not found, so there is no point looking again. */ boolean report; /* Collecting messages about what we removed? */ struct hash *seen; /* Messages reported already. */ struct slName *removed; /* Messages, in the order we first hit them. */ }; static void noteRemoved(struct sanitizer *san, char *format, ...) /* Record a message naming something we took out, once per distinct message. */ { if (!san->report) return; char message[512]; va_list args; va_start(args, format); vsnprintf(message, sizeof message, format, args); va_end(args); if (hashLookup(san->seen, message) != NULL) return; hashAdd(san->seen, message, NULL); slNameAddHead(&san->removed, message); } static char *findTagEnd(char *s) /* Given s just inside a '<', return the '>' that ends the tag, stepping over quoted * attribute values. Return NULL if the tag is never closed. A quote only opens a value * where a value can start, which is how a browser reads it too, so a stray quote in the * middle of an unquoted value does not swallow the rest of the page. */ { char quote = 0; boolean expectValue = FALSE; /* just past an '=', the value has not started yet */ boolean inBareValue = FALSE; /* inside an unquoted value, where a quote is just a character */ for (; *s != 0; ++s) { if (quote != 0) { if (*s == quote) quote = 0; } else if (isspace((unsigned char)*s)) inBareValue = FALSE; else if (inBareValue) { if (*s == '>') return s; } else if (expectValue) { expectValue = FALSE; if (*s == '"' || *s == '\'') quote = *s; else if (*s == '>') return s; else inBareValue = TRUE; } else if (*s == '=') expectValue = TRUE; else if (*s == '>') return s; } return NULL; } static boolean isNameChar(char c) /* Is c part of an element or attribute name? */ { return isalnum((unsigned char)c) || c == ':' || c == '_' || c == '-' || c == '.'; } +static boolean allNameChars(char *s) +/* Is every character in s one that belongs in a name? A message we hand back names an + * attribute the page wrote, and that text can end up in a terminal. */ +{ +for (; *s != 0; ++s) + { + if (!isNameChar(*s)) + return FALSE; + } +return TRUE; +} + static char *tagName(char *s, char *name, int nameSize) /* Copy the element name starting at s into name, lower cased. Return the first character * after the name. */ { int i = 0; while (isNameChar(*s)) { if (i < nameSize-1) name[i++] = *s; ++s; } name[i] = 0; tolowers(name); return s; } static char *skipToClose(char *s, char *name, boolean rawText) /* s points just after the opening tag of name. Return the first character after the * matching closing tag, or NULL if there is no closing tag. */ { int depth = 1; int nameLen = strlen(name); char *p = s; while ((p = strchr(p, '<')) != NULL) { boolean closing = (p[1] == '/'); char *q = p + (closing ? 2 : 1); if (strncasecmp(q, name, nameLen) == 0 && !isNameChar(q[nameLen])) { char *e = findTagEnd(q); if (e == NULL) return NULL; if (closing) { depth -= 1; if (depth == 0) return e+1; } else if (!rawText && e[-1] != '/') depth += 1; p = e+1; } else p += 1; } return NULL; } static char *nextAttribute(char *s, char *tagEnd, char **retName, int *retNameLen, char **retVal, int *retValLen) /* Pick the next attribute out of the text between s and tagEnd. Return the first character * after it, or NULL when there are no more. A name length of zero means junk we skipped. */ { while (s < tagEnd && (isspace((unsigned char)*s) || *s == '/')) ++s; if (s >= tagEnd) return NULL; char *nameStart = s; while (s < tagEnd && !isspace((unsigned char)*s) && *s != '=' && *s != '/') ++s; *retName = nameStart; *retNameLen = s - nameStart; *retVal = NULL; *retValLen = 0; if (*retNameLen == 0) return s+1; /* junk, but keep moving */ char *afterName = s; while (s < tagEnd && isspace((unsigned char)*s)) ++s; if (s >= tagEnd || *s != '=') return afterName; ++s; while (s < tagEnd && isspace((unsigned char)*s)) ++s; if (s < tagEnd && (*s == '"' || *s == '\'')) { char quote = *s++; *retVal = s; while (s < tagEnd && *s != quote) ++s; *retValLen = s - *retVal; if (s < tagEnd) ++s; } else { *retVal = s; while (s < tagEnd && !isspace((unsigned char)*s)) ++s; *retValLen = s - *retVal; } return s; } static void appendEscaped(struct dyString *dy, char *s) /* Append s as an attribute value, hiding the characters that could end the attribute or * start a tag. Ampersands are left alone so that entities the author wrote stay as * they are. */ { for (; *s != 0; ++s) { switch (*s) { case '"': dyStringAppend(dy, """); break; case '<': dyStringAppend(dy, "<"); break; case '>': dyStringAppend(dy, ">"); break; default: dyStringAppendC(dy, *s); break; } } } static char *decodeNumericRefs(char *s) /* Return a copy of s with numeric character references turned into the characters they * name, which is what a browser does before it looks for a scheme. Only &#NN and &#xNN * are decoded, with or without the closing semicolon, because that is what a browser * accepts. A named entity is left alone and the caller refuses the URL over it. A * character above ASCII cannot be part of a scheme, so one stand-in character does for * all of them. */ { struct dyString *dy = dyStringNew(strlen(s)+1); char *p = s; while (*p != 0) { if (p[0] == '&' && p[1] == '#') { char *digits = p+2; int base = 10; if (*digits == 'x' || *digits == 'X') { base = 16; digits += 1; } char *end = NULL; errno = 0; long value = strtol(digits, &end, base); if (end != digits) { if (*end == ';') end += 1; if (value > 0 && value < 128 && errno == 0) dyStringAppendC(dy, (char)value); else dyStringAppendC(dy, '~'); p = end; continue; } } dyStringAppendC(dy, *p); p += 1; } return dyStringCannibalize(&dy); } static char *urlScheme(char *val, boolean *retSuspect) /* Return the scheme of val, lower cased and freshly allocated, or NULL if it has none. * Set retSuspect when the text in front of the path holds something that could hide a * scheme from us and still be one to a browser: a named entity, a backslash, or a control * character. Reading the text this way, rather than copying every rule a browser has for * repairing a broken URL, is the point. Matching those rules exactly is how a check like * this gets beaten. */ { *retSuspect = FALSE; char *decoded = decodeNumericRefs(val); char *s = decoded; while (*s != 0 && (unsigned char)*s <= ' ') ++s; char *scheme = NULL; char *p; for (p = s; *p != 0; ++p) { if (*p == '/' || *p == '?' || *p == '#') break; /* a path, query or anchor starts, so no scheme */ if (*p == '&' || *p == '\\' || (unsigned char)*p < ' ' || *p == 0x7f) { *retSuspect = TRUE; break; } if (*p == ':') { int len = p - s; char buf[33]; if (len < 1 || len >= sizeof buf) { *retSuspect = TRUE; break; } memcpy(buf, s, len); buf[len] = 0; tolowers(buf); boolean plain = isalpha((unsigned char)buf[0]); char *c; for (c = buf; plain && *c != 0; ++c) { if (!isalnum((unsigned char)*c) && *c != '+' && *c != '.' && *c != '-') plain = FALSE; } if (plain) scheme = cloneString(buf); else *retSuspect = TRUE; break; } } freeMem(decoded); return scheme; } static boolean urlOk(char *val, struct sanitizer *san) /* Is this a URL we are willing to print? */ { boolean suspect = FALSE; char *scheme = urlScheme(val, &suspect); if (suspect) { noteRemoved(san, "removed a link that does not read as a plain web address"); return FALSE; } if (scheme == NULL) return TRUE; boolean ok = (hashLookup(schemeHash, scheme) != NULL); if (!ok) noteRemoved(san, "removed a link that used the %s: scheme", scheme); freeMem(scheme); return ok; } static boolean iframeSrcOk(char *src) /* Does src point at one of the video hosts we allow in a frame? */ { if (isEmpty(src)) return FALSE; boolean suspect = FALSE; char *scheme = urlScheme(src, &suspect); if (suspect) return FALSE; if (scheme != NULL) { boolean https = sameString(scheme, "https"); freeMem(scheme); if (!https) return FALSE; } else if (!startsWith("//", skipLeadingSpaces(src))) return FALSE; char *host = stringIn("//", src); if (host == NULL) return FALSE; host += 2; int len = strcspn(host, "/?#:"); char hostName[256]; if (len >= sizeof hostName) return FALSE; memcpy(hostName, host, len); hostName[len] = 0; tolowers(hostName); return (hashLookup(videoHostHash, hostName) != NULL); } static void stripCssComments(char *s) /* Blank out CSS comments in place. */ { char *open; while ((open = stringIn("/*", s)) != NULL) { char *close = stringIn("*/", open+2); char *end = (close == NULL ? open + strlen(open) : close+2); while (open < end) *open++ = ' '; s = end; } } static char *filterStyle(char *val, struct sanitizer *san) /* Return the declarations of val that we allow, or NULL if none of them survive. */ { char *dupe = cloneString(val); stripCssComments(dupe); struct dyString *out = dyStringNew(strlen(dupe)+1); char *decl = dupe; while (decl != NULL && *decl != 0) { char *next = strchr(decl, ';'); if (next != NULL) *next++ = 0; char *colon = strchr(decl, ':'); if (colon != NULL) { *colon = 0; char *prop = trimSpaces(decl); char *value = trimSpaces(colon+1); tolowers(prop); if (isNotEmpty(prop) && isNotEmpty(value)) { char *lower = cloneString(value); tolowers(lower); if (hashLookup(stylePropHash, prop) == NULL) ; /* not a property we print, and nothing to explain */ else if (stringIn("url(", lower) != NULL || stringIn("expression", lower) != NULL || strchr(lower, '\\') != NULL) noteRemoved(san, "removed the value of the style property %s", prop); else dyStringPrintf(out, "%s:%s;", prop, value); freeMem(lower); } } decl = next; } freeMem(dupe); if (out->stringSize == 0) { dyStringFree(&out); return NULL; } return dyStringCannibalize(&out); } static void writeAttributes(struct sanitizer *san, char *element, char *attrText, char *tagEnd) /* Write the attributes of element that we allow, from the text between attrText and tagEnd. */ { boolean isAnchor = sameString(element, "a"); boolean isFrame = sameString(element, "iframe"); boolean hasTarget = FALSE; char *relValue = NULL; char *s = attrText; char *name, *val; int nameLen, valLen; while ((s = nextAttribute(s, tagEnd, &name, &nameLen, &val, &valLen)) != NULL) { if (nameLen == 0 || nameLen > 128) continue; char attr[129]; memcpy(attr, name, nameLen); attr[nameLen] = 0; tolowers(attr); char key[256]; safef(key, sizeof key, "%s.%s", element, attr); if (hashLookup(attrHash, key) == NULL) { safef(key, sizeof key, "*.%s", attr); if (hashLookup(attrHash, key) == NULL) { - if (startsWith("on", attr)) + if (startsWith("on", attr) && allNameChars(attr)) noteRemoved(san, "removed the attribute %s", attr); continue; } } char *value = cloneStringZ(val == NULL ? "" : val, valLen); if (sameString(attr, "style")) { char *style = filterStyle(value, san); if (style != NULL) { dyStringAppend(san->out, " style=\""); appendEscaped(san->out, style); dyStringAppendC(san->out, '"'); freeMem(style); } } else if (sameString(attr, "href") || sameString(attr, "src")) { if (urlOk(value, san)) { dyStringPrintf(san->out, " %s=\"", attr); appendEscaped(san->out, value); dyStringAppendC(san->out, '"'); } } else if (isAnchor && sameString(attr, "rel")) { freez(&relValue); relValue = cloneString(value); } else { if (isAnchor && sameString(attr, "target")) hasTarget = TRUE; dyStringPrintf(san->out, " %s=\"", attr); appendEscaped(san->out, value); dyStringAppendC(san->out, '"'); } freeMem(value); } if (isAnchor && (hasTarget || relValue != NULL)) { /* A link that opens a new window hands that window a handle back to ours unless we * say otherwise. */ dyStringAppend(san->out, " rel=\""); if (relValue != NULL) { appendEscaped(san->out, relValue); dyStringAppendC(san->out, ' '); } dyStringAppend(san->out, "noopener noreferrer\""); } if (isFrame) dyStringAppend(san->out, " sandbox=\"allow-scripts allow-same-origin allow-popups" " allow-presentation\""); freez(&relValue); } static char *attributeValue(char *attrText, char *tagEnd, char *wanted) /* Return a copy of the value of the named attribute, or NULL if the tag has no such * attribute. */ { char *s = attrText; char *name, *val; int nameLen, valLen; while ((s = nextAttribute(s, tagEnd, &name, &nameLen, &val, &valLen)) != NULL) { if (nameLen > 0 && nameLen == strlen(wanted) && strncasecmp(name, wanted, nameLen) == 0) return cloneStringZ(val == NULL ? "" : val, valLen); } return NULL; } static void closeThrough(struct sanitizer *san, char *name) /* Close name, and anything opened inside it, if name is open at all. */ { struct slName *el; boolean found = FALSE; for (el = san->openStack; el != NULL; el = el->next) { if (sameString(el->name, name)) { found = TRUE; break; } } if (!found) return; while (san->openStack != NULL) { struct slName *top = slPopHead(&san->openStack); san->depth -= 1; dyStringPrintf(san->out, "</%s>", top->name); boolean done = sameString(top->name, name); freeMem(top); if (done) break; } } static void sanitizeOnePass(char *html, struct sanitizer *san) /* Walk html, writing what we allow into san->out. */ { char *s = html; while (*s != 0) { char *lt = strchr(s, '<'); if (lt == NULL) { dyStringAppend(san->out, s); break; } if (lt > s) dyStringAppendN(san->out, s, lt - s); s = lt; if (startsWith("<!--", s)) { char *end = stringIn("-->", s+4); s = (end == NULL ? s + strlen(s) : end+3); continue; } if (s[1] == '!' || s[1] == '?') { char *end = strchr(s, '>'); s = (end == NULL ? s + strlen(s) : end+1); continue; } boolean closing = (s[1] == '/'); char *nameStart = s + (closing ? 2 : 1); if (!isalpha((unsigned char)*nameStart)) { dyStringAppendC(san->out, '<'); s += 1; continue; } char *tagEnd = findTagEnd(nameStart); if (tagEnd == NULL) break; /* tag with no end, drop what is left */ char name[64]; char *attrText = tagName(nameStart, name, sizeof name); s = tagEnd + 1; if (closing) { if (hashLookup(keepHash, name) != NULL && hashLookup(voidHash, name) == NULL) closeThrough(san, name); continue; } boolean isVoid = (hashLookup(voidHash, name) != NULL); boolean kill = (hashLookup(killHash, name) != NULL); boolean noted = FALSE; if (!kill && sameString(name, "iframe")) { char *src = attributeValue(attrText, tagEnd, "src"); kill = !iframeSrcOk(src); if (kill) { noteRemoved(san, "removed an iframe, we only allow one that plays a video " "from a site we know"); noted = TRUE; } freeMem(src); } if (kill) { if (!isVoid && tagEnd[-1] != '/') { boolean rawText = (hashLookup(rawTextHash, name) != NULL); /* Once the search for a closing tag has run off the end of the input, every * later search for that same tag will too, and repeating it on a page built * of thousands of unclosed tags would cost us a pass each time. */ char *afterClose = NULL; if (hashLookup(san->exhausted, name) == NULL) { afterClose = skipToClose(s, name, rawText); if (afterClose == NULL) hashAdd(san->exhausted, name, NULL); } if (afterClose != NULL) s = afterClose; else if (rawText) s += strlen(s); /* never closed, and its content is not for reading */ } if (!noted && hashLookup(silentKillHash, name) == NULL) noteRemoved(san, "removed the %s element and everything inside it", name); continue; } if (hashLookup(keepHash, name) == NULL) continue; /* tag goes, text inside it stays */ if (!isVoid && san->depth >= maxNestDepth) continue; dyStringPrintf(san->out, "<%s", name); writeAttributes(san, name, attrText, tagEnd); dyStringAppendC(san->out, '>'); if (!isVoid) { /* A trailing slash does not close an element like this one, whatever the author * meant by it, so remember it as open. Anything still open at the end is closed * for us, which stops a page ending up inside a hub's div. */ slNameAddHead(&san->openStack, name); san->depth += 1; } } while (san->openStack != NULL) { struct slName *top = slPopHead(&san->openStack); dyStringPrintf(san->out, "</%s>", top->name); freeMem(top); } } char *htmlSanitizeReport(char *html, struct slName **retRemoved) /* Like htmlSanitize, and if retRemoved is not NULL also return a list of one-line messages * naming each kind of thing that was removed. The list is NULL when nothing was removed. */ { if (retRemoved != NULL) *retRemoved = NULL; if (html == NULL) return NULL; initTables(); struct sanitizer san; ZeroVar(&san); san.out = dyStringNew(strlen(html) + 128); san.report = (retRemoved != NULL); san.exhausted = hashNew(6); if (retRemoved != NULL) san.seen = hashNew(0); sanitizeOnePass(html, &san); hashFree(&san.exhausted); if (retRemoved != NULL) { slReverse(&san.removed); *retRemoved = san.removed; hashFree(&san.seen); } return dyStringCannibalize(&san.out); } char *htmlSanitize(char *html) /* Return a cloned copy of html holding only allowlisted elements, attributes and style * properties. */ { return htmlSanitizeReport(html, NULL); }