9ad04e0a0b06ec3c4f09ef1b6c3ce6be79b61c68 braney Sun Aug 16 11:56:56 2026 -0700 cart: validate file names read back out of the cart Several cart variables hold the name of a file the server created for a user. Route them through one shared check, isServerUserFilePath(), which accepts the trash directory, the session-data directories and myVariantsDataDir, and apply it both where values enter the cart and where the file names are used. A few of these variables may instead hold a remote URL. Those get their own list and isServerUserFileOrUrl(), because the code that reads them chooses between a fetch and a local open by looking for a protocol. Consolidates two hand-rolled copies of the same test in blatShare.c and customFactory.c, and drops the weaker private copy in sessionData.c. Adds hg/utils/cartFileVarCatalog, a registry that scans the tree for a cart value reaching a file call and reconciles what it finds against the lists in cart.c, so a new one of these cannot be added without somebody noticing. Its --reconcile is quiet enough for the nightly cron the other catalogs use, and it is what turned up seven of the names now on those lists. refs #37623 diff --git src/hg/utils/cartFileVarCatalog/cartFileVarCatalog.py src/hg/utils/cartFileVarCatalog/cartFileVarCatalog.py new file mode 100755 index 00000000000..ad79f0bf487 --- /dev/null +++ src/hg/utils/cartFileVarCatalog/cartFileVarCatalog.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""cartFileVarCatalog.py - the registry of cart variables that hold a file name. + +Refs #37623. Most cart variables hold a setting. A few hold the name of a +file the server made for the user, and a CGI reads one back out of the cart and +opens it. Those are the ones an attacker can retarget, so hg/lib/cart.c screens +them on the way in, against two hand-written arrays: + + fileNameCartVars[] exact names + fileNameCartVarPrefixes[] families whose name carries the db or an id + +A hand-written list goes stale the moment somebody adds a variable and does not +know the list exists. It was already incomplete on the day it was written. So +this catalog says, for every cart variable the tree opens as a file, whether +cart.c screens it and why, and --reconcile checks that claim against both the +tree and cart.c itself. + +Usage: + cartFileVarCatalog.py --list # the catalog, grouped by verdict + cartFileVarCatalog.py --check # sanity checks on this file alone + cartFileVarCatalog.py --reconcile # diff the catalog against the tree + cartFileVarCatalog.py --reconcile --verbose + cartFileVarCatalog.py --json out.json + cartFileVarCatalog.py --html out.html + +--reconcile is the mode meant for a nightly cron: silent and exit 0 when +nothing has changed, non-zero with a list when something has. Four things make +it fail, and each one is somebody having to do something: + + 1. the tree opens a cart variable that is not in this catalog at all. That + is a new file-name variable nobody classified. + 2. an entry says cart.c screens it and cart.c no longer does. That is a line + deleted from fileNameCartVars[]. + 3. an entry says cart.c does not screen it and cart.c now does. Somebody + fixed a gap; the entry needs to say so. + 4. cart.c screens a name this catalog has never heard of. Somebody added to + the array without saying what the variable is. + +Unlike its sibling catalogs there is no baseline file of names deliberately +left undescribed. The whole set is under two dozen names, so every one of them +gets a row and a verdict, and the strictness is the point. + +--check is a different thing and not a substitute: it only reads this file, so +it cannot see the tree move at all. +""" + +import argparse +import html +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import harvestCartFileVars as harvest # noqa: E402 + + +# Floor on how many cart-to-file flows a working scan finds; well under the +# real count. Pointed at an empty tree or the wrong KENT_SRC every check below +# would otherwise come up empty and report all clear forever. +MIN_TREE_FLOWS = 12 + +# The verdicts. Every entry has exactly one. +VERDICTS = { + "screened": + "cart.c screens it, and reconcile checks that it still does", + "gap": + "holds a server file name and cart.c does not screen it", + "trackDbNamed": + "the cart variable is named by a trackDb setting, so no fixed list can " + "cover it; the check has to be at the point of use", + "otherCheck": + "the value is validated where it is used, by a check of its own", + "notBuilt": + "the CGI is in the tree but not in hg/makefile", +} + + +def e(name, verdict, where, sink=None, ident=None, screen=None, note=None): + """One cart variable. + + name the cart variable, or a -style pattern for a family + ident what the harvester reports when the name is built at run time + where the file that opens the value + sink the call that opens it + screen the entry in cart.c that covers it, exact name or "prefix:x" + """ + return dict(name=name, verdict=verdict, where=where, sink=sink, + ident=ident, screen=screen, note=note) + + +CATALOG = [ + + # ---- screened by cart.c ------------------------------------------------- + + e("ctfile_", "screened", "hg/lib/customTrack.c", "fileExists", + ident="{ctFileVar}", screen="prefix:ctfile_", + note="The custom track file. Also ctfile_hub_ for a hub's assembly, " + "and the name is built from the db at run time, which is why the " + "cart.c entry is a prefix rather than a name."), + + e("mvCtfile_", "screened", "hg/lib/customTrack.c", "fileExists", + ident="{mvVar}", screen="prefix:mvCtfile_", + note="myVariants custom track. Its file lives under myVariantsDataDir " + "when hg.conf sets one, which is why isServerUserFilePath() accepts " + "that directory and the bigDataUrl check does not."), + + e("customComposite-", "screened", "hg/hgCollection/hgCollection.c", + "open, mustOpen", ident="{buffer}", screen="prefix:customComposite-", + note="The track collection hub. hgCollection writes it, so a retargeted " + "value is a write, not only a read. The write path also needs a " + "logged-in session (hgCollection.c:1007); the read path does not."), + + e("hubQuickLift-", "screened", "hg/lib/trackHub.c", + "open, mustOpen, chmod", ident="{buffer}", + screen="prefix:hubQuickLift-", + note="The quickLift hub. Shares the harvested token with " + "customComposite- because both names are built into a local " + "buffer by safef()."), + + e("hgta_userRegionsFile", "screened", "hg/hgTables/userRegions.c", + "fileExists, lineFileOpen", screen="hgta_userRegionsFile", + note="Also read by hgIntegrator, which unlinks it. userRegionsFileName() " + "returns NULL on a bad value without calling " + "cartRemoveUserRegions(); removing the regions instead would delete " + "the user's own region list, which is how the first attempt at this " + "fix destroyed data."), + + e("hgg_mrnaFoldPs", "screened", "hg/hgGene/rnaStructure.c", "fileExists", + screen="hgg_mrnaFoldPs", + note="PostScript for the mRNA fold picture. hgGene runs ps2pdf on it " + "and writes the .pdf beside it, so this one is a write too."), + + e("hgp_matchFile", "screened", "hg/visiGene/hgVisiGene/hgVisiGene.c", + "lineFileOpen", screen="hgp_matchFile", + note="hgVisiGene search matches. Filled from trashDirFile() at " + "hgVisiGene.c:536. No flow is harvested because the open is in a " + "different function from the cart read."), + + e("dup_tracks", "screened", "hg/lib/dupTrack.c", "fileExists, mustOpen", + screen="dup_tracks", + note="Duplicated track stanzas. Both reads now go through " + "dupFileNameFromCart(), which puts the cart read and the open in " + "different functions, so the scan no longer sees the flow. That is " + "the scan's main blind spot; the entry is what keeps the name " + "covered anyway."), + + e("blatLastBigBed", "screened", "hg/lib/blatShare.c", "bigBedFileOpen", + screen="blatLastBigBed", + note="BLAT bigPsl, read by blatFindPinnedBigPsl(). Had its own " + "hand-rolled copy of the allow-list before this work folded it into " + "isTrashOrSessionDataPath()."), + + # ---- found by the scan, screened afterwards ------------------------------ + # None of these seven was in cart.c when the fix was written. The scan + # found them, which is the argument for having the scan. + + e("hgta_identifierFile", "screened", "hg/hgTables/identifiers.c", + "fileExists, lineFileOpen", screen="hgta_identifierFile", + note="The pasted identifier list, normally a trash file. " + "identifierFileName() checked only fileExists() before, then " + "identifiers.c opened it. Returns NULL on a bad value without " + "removing the cart variable, same reasoning as user regions."), + + e("blatPslFile", "screened", "hg/hgBlat/hgBlat.c", + "fileExists, showAliPlaces", screen="blatPslFile", + note="The saved BLAT result, a trash file set at hgBlat.c:2323. Its " + "sibling blatLastBigBed was screened from the start and these two " + "were not."), + + e("blatFaFile", "screened", "hg/hgBlat/hgBlat.c", "showAliPlaces", + screen="blatFaFile", + note="The other half of the saved BLAT result. No flow is harvested: it " + "reaches showAliPlaces() without a fileExists() of its own, which is " + "exactly the shape the scan cannot see. Caught by reading the code " + "next to blatPslFile, not by the scan."), + + e("near.customFile", "screened", "hg/near/hgNear/customColumn.c", + "fileExists", screen="near.customFile", + note="hgNear custom column file, normally makeTempName(near, .col). " + "hgNear is in hg/makefile, so this one is built and reachable."), + + e("gsTemp", "screened", "hg/hgTables/genomeSpace.c", + "fileSize, md5ForFile, gsS3Upload", screen="gsTemp", + note="The file hgTables uploads to GenomeSpace. A retargeted value is " + "read and sent to a remote service, not only read locally, so the " + "point-of-use check errAborts rather than returning quietly. Gated " + "as well: genomeSpace is enabled only by the presence of its hg.conf " + "settings (genomeSpace.c:110) and no conf in confs/ sets them."), + + # ---- either a URL or a file we made -------------------------------------- + # Screened against urlOrFileNameCartVars[] with isServerUserFileOrUrl(), + # because isServerUserFilePath() alone would reject every legitimate URL. + + e("multiRegionsBedUrl", "screened", "hg/hgTracks/config.c", + "fileExists, lineFileMayOpen", screen="urlOrFile:multiRegionsBedUrl", + note="Multi-region custom BED: either a URL the user gave, or the trash " + "file hgTracks wrote the pasted BED to (hgTracks.c:4357). The code " + "picks the branch by looking for '://', so a value with no protocol " + "falls through to opening a local file. config.c prints what it " + "reads back into the multi-region textarea, so an unscreened value " + "was a file read the user could see. Both local-file branches now " + "check the path as well."), + + e("hgS_loadUrlName", "screened", "hg/lib/cart.c", "netLineFileOpen", + screen="urlOrFile:hgS_loadUrlName", + note="hgSession load-settings-from-URL. netUrlOpen() treats a string " + "with no '://' as a local path and open()s it (lib/net.c:1518), so " + "this reached a local file even though the name says URL. What it " + "could do with the contents is bounded: they are parsed as session " + "settings."), + + # ---- covered another way ------------------------------------------------ + + e("", "trackDbNamed", "hg/lib/cart.c", + "lineFileOpen", ident="{speciesUseFile}", + note="The speciesUseFile trackDb setting names a cart variable, so the " + "variable's name is data, not code, and no fixed array can list it. " + "cartGetOrderFromFile() checks the value with isServerUserFilePath() " + "at the point of use instead. The only user in the tree is " + "hiv/hivgne8v2/trackDb.ra naming gsidTable.gsidSeqList."), + + e("fileUrl", "otherCheck", "hg/hgTrackUi/hgTrackUi.c", "udcFileMayOpen", + note="hgTrackUi's file-fetch handler. Has a purpose-built check of its " + "own: resolveDotDots() then the value must fall under a connected " + "hub's base directory or match a whitelisted trackDb setting, or it " + "errAborts (hgTrackUi.c:4384). Not a trash file, so the cart.c " + "allow-list is the wrong check for it."), + + # ---- not built ---------------------------------------------------------- + + e("gsidTable.gsidSubjList", "notBuilt", "hg/gsid/gsidTable/gsidTable.c", + "mustOpen", + note="gsid is not in hg/makefile. Its value is a trashDirFile() path, " + "which is also why cartGetOrderFromFile() can check it."), + e("gsidTable.gsidSeqList", "notBuilt", "hg/gsid/gsidTable/gsidTable.c", + "mustOpen", note="gsid is not in hg/makefile."), + e("gisaidTable.gisaidSubjList", "notBuilt", + "hg/gisaid/gisaidTable/gisaidTable.c", "mustOpen", + note="gisaid is not in hg/makefile."), + e("gisaidTable.gisaidSeqList", "notBuilt", + "hg/gisaid/gisaidTable/gisaidTable.c", "mustOpen", + note="gisaid is not in hg/makefile."), + e("gisaidTable.gisaidAaSeqList", "notBuilt", + "hg/gisaid/gisaidTable/gisaidTable.c", "mustOpen", + note="gisaid is not in hg/makefile."), +] + + +# --------------------------------------------------------------------------- +# lookups +# --------------------------------------------------------------------------- + +def by_harvest_key(cat): + """Every token the harvester can report, mapped to its entries. + + A name built at run time comes out of the scan as {ident}, and one ident + can stand for more than one family: hgCollection and trackHub both safef() + their name into a local called buffer. + """ + out = {} + for entry in cat: + for key in (entry["ident"], entry["name"]): + if key and not ("<" in key and ">" in key): + out.setdefault(key, []).append(entry) + if entry["ident"]: + out.setdefault(entry["ident"], []) + if entry not in out[entry["ident"]]: + out[entry["ident"]].append(entry) + return out + + +def screen_entries(cat): + """The cart.c entries the catalog claims, one set per array.""" + names, prefixes, urlOrFile = set(), set(), set() + for entry in cat: + s = entry["screen"] + if not s: + continue + if s.startswith("prefix:"): + prefixes.add(s[len("prefix:"):]) + elif s.startswith("urlOrFile:"): + urlOrFile.add(s[len("urlOrFile:"):]) + else: + names.add(s) + return names, prefixes, urlOrFile + + +# --------------------------------------------------------------------------- +# --check +# --------------------------------------------------------------------------- + +def check(cat, out=sys.stderr): + """Sanity checks on this file alone. Returns the number of problems.""" + problems = 0 + seen = set() + for entry in cat: + name = entry["name"] + if name in seen: + print("duplicate entry: %s" % name, file=out) + problems += 1 + seen.add(name) + if entry["verdict"] not in VERDICTS: + print("%s: unknown verdict %r" % (name, entry["verdict"]), file=out) + problems += 1 + if entry["verdict"] == "screened" and not entry["screen"]: + print("%s: verdict screened but no screen= given" % name, file=out) + problems += 1 + if entry["verdict"] != "screened" and entry["screen"]: + print("%s: screen= given but verdict is %s" + % (name, entry["verdict"]), file=out) + problems += 1 + if not entry["where"] or not entry["note"]: + print("%s: needs both where= and note=" % name, file=out) + problems += 1 + + counts = {} + for entry in cat: + counts[entry["verdict"]] = counts.get(entry["verdict"], 0) + 1 + print("entries %d" % len(cat), file=out) + for verdict in sorted(counts): + print(" %-14s %d" % (verdict, counts[verdict]), file=out) + print("problems %d" % problems, file=out) + return problems + + +# --------------------------------------------------------------------------- +# --reconcile +# --------------------------------------------------------------------------- + +def reconcile(cat, out=sys.stdout, verbose=False): + """Diff the catalog against the tree and against cart.c. + + Silent and 0 when nothing has changed. See the module docstring for the + four things that make it fail. + """ + h = harvest.harvest() + flows = h["flows"] + + if len(flows) < MIN_TREE_FLOWS: + print("only %d cart-to-file flows found: expected at least %d, so the " + "scan is\nbroken rather than the tree being clean. Check " + "KENT_SRC." % (len(flows), MIN_TREE_FLOWS), file=out) + return 1 + + missing = [name for name, key in zip(harvest.SCREEN_ARRAYS, + ("screenNames", "screenPrefixes", + "screenUrlOrFile")) + if h[key] is None] + if missing: + print("no %s array found in %s: either it was removed or it was " + "reformatted\npast what read_screen() can parse. Either way " + "nothing in it is being screened." + % (", ".join(missing), harvest.CART_C), file=out) + return 1 + + tree_names = set(h["screenNames"]) + tree_prefixes = set(h["screenPrefixes"]) + tree_urlOrFile = set(h["screenUrlOrFile"]) + keys = by_harvest_key(cat) + problems = 0 + + # 1. the tree opens something this catalog has never heard of + found = sorted({f["name"] for f in flows}) + site = {} + for f in flows: + site.setdefault(f["name"], "%s:%d" % (f["file"], f["line"])) + unknown = [n for n in found if n not in keys] + if unknown: + problems += len(unknown) + print("\nopened as a file by the tree, not in the catalog (%d):" + % len(unknown), file=out) + print(" (add a row to cartFileVarCatalog.py saying what the " + "variable is,\n and if it holds a server file name add it to " + "%s in %s too)" + % (harvest.SCREEN_ARRAYS[0], harvest.CART_C), file=out) + for n in unknown: + print(" %-32s %s" % (n, site.get(n, "")), file=out) + + # 2 and 3. every claim about cart.c, checked against cart.c + lost, fixed = [], [] + for entry in cat: + name = entry["screen"] + if entry["verdict"] == "screened": + if name.startswith("prefix:"): + if name[len("prefix:"):] not in tree_prefixes: + lost.append(entry) + elif name.startswith("urlOrFile:"): + if name[len("urlOrFile:"):] not in tree_urlOrFile: + lost.append(entry) + elif name not in tree_names: + lost.append(entry) + elif entry["verdict"] in ("gap", "otherCheck"): + probe = entry["name"] + if "<" not in probe and harvest.screened(probe, tree_names, + tree_prefixes, + tree_urlOrFile): + fixed.append(entry) + + if lost: + problems += len(lost) + print("\nthe catalog says cart.c screens these and it does not (%d):" + % len(lost), file=out) + for entry in lost: + print(" %-32s wanted %s" % (entry["name"], entry["screen"]), + file=out) + + if fixed: + problems += len(fixed) + print("\ncart.c now screens these and the catalog still calls them a " + "gap (%d):" % len(fixed), file=out) + print(" (change the verdict to screened and set screen=)", file=out) + for entry in fixed: + print(" %s" % entry["name"], file=out) + + # 4. cart.c screens something the catalog does not describe at all. A + # variable the catalog knows as a gap is not an orphan: adding it to cart.c + # is the fix, and check 3 above already asks for the verdict to be updated. + cat_names, cat_prefixes, cat_urlOrFile = screen_entries(cat) + known = {entry["name"] for entry in cat} + orphan = sorted( + {n for n in tree_names - cat_names if n not in known} + | {n for n in tree_urlOrFile - cat_urlOrFile if n not in known} + | {"prefix:" + p for p in tree_prefixes - cat_prefixes + if not any(k.startswith(p) for k in known)}) + if orphan: + problems += len(orphan) + print("\nscreened by %s, not described in the catalog (%d):" + % (harvest.CART_C, len(orphan)), file=out) + for n in orphan: + print(" %s" % n, file=out) + + if verbose: + print("\ncatalog entries %d" % len(cat), file=out) + print("tree flows %d over %d names" % (len(flows), len(found)), + file=out) + print("cart.c screens %d names, %d prefixes, %d url-or-file" + % (len(tree_names), len(tree_prefixes), len(tree_urlOrFile)), + file=out) + quiet = [entry["name"] for entry in cat + if entry["name"] not in found + and (entry["ident"] or entry["name"]) not in found] + print("\nin the catalog, no flow found in the tree (%d)" % len(quiet), + file=out) + print(" (expected: the scan is intraprocedural, so a value handed " + "to a\n helper that opens it does not show up)", file=out) + for n in quiet: + print(" %s" % n, file=out) + gaps = [entry["name"] for entry in cat if entry["verdict"] == "gap"] + print("\nknown gaps, unscreened by cart.c (%d)" % len(gaps), file=out) + for n in gaps: + print(" %s" % n, file=out) + + return problems + + +# --------------------------------------------------------------------------- +# output +# --------------------------------------------------------------------------- + +def show(cat, out=sys.stdout): + for verdict in ("screened", "gap", "trackDbNamed", "otherCheck", + "notBuilt"): + rows = [entry for entry in cat if entry["verdict"] == verdict] + if not rows: + continue + print("\n%s - %s" % (verdict, VERDICTS[verdict]), file=out) + for entry in rows: + print(" %-30s %s" % (entry["name"], entry["where"]), file=out) + if entry["screen"]: + print(" %-30s cart.c: %s" % ("", entry["screen"]), file=out) + + +def render_html(cat): + def esc(s): + return html.escape(s or "") + + parts = ["

Cart variables that hold a file name

", + "

Refs #37623. Generated by " + "hg/utils/cartFileVarCatalog/cartFileVarCatalog.py. " + "A cart variable in this table holds the name of a file the " + "server made for the user, which means a CGI opens whatever the " + "cart says. hg/lib/cart.c screens the ones marked " + "screened on the way in.

"] + for verdict in ("screened", "gap", "trackDbNamed", "otherCheck", + "notBuilt"): + rows = [entry for entry in cat if entry["verdict"] == verdict] + if not rows: + continue + parts.append("

%s

%s

" % (esc(verdict), + esc(VERDICTS[verdict]))) + parts.append("") + parts.append("
cart variableopened incall" + "cart.cnote") + for entry in rows: + parts.append("
%s%s" + "%s%s%s" + % (esc(entry["name"]), esc(entry["where"]), + esc(entry["sink"]), esc(entry["screen"]), + esc(entry["note"]))) + parts.append("
") + return "\n".join(parts) + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--list", action="store_true", + help="the catalog, grouped by verdict") + ap.add_argument("--check", action="store_true", + help="sanity checks on this file alone") + ap.add_argument("--reconcile", action="store_true", + help="diff the catalog against the tree and cart.c") + ap.add_argument("--verbose", action="store_true", + help="with --reconcile, also print the standing state") + ap.add_argument("--json", metavar="FILE") + ap.add_argument("--html", metavar="FILE") + args = ap.parse_args() + + if args.check: + return 1 if check(CATALOG) else 0 + if args.reconcile: + return 1 if reconcile(CATALOG, verbose=args.verbose) else 0 + if args.json: + with open(args.json, "w") as f: + json.dump(CATALOG, f, indent=2, sort_keys=True) + print("wrote %s" % args.json) + return 0 + if args.html: + with open(args.html, "w") as f: + f.write(render_html(CATALOG)) + print("wrote %s" % args.html) + return 0 + + show(CATALOG) + return 0 + + +if __name__ == "__main__": + sys.exit(main())