4d9755a177d6b6660e589ec9f322e3b932fdb391
braney
  Sun Aug 23 15:13:54 2026 -0700
ts: scope the CORS header to the data directories, refs #37867

The per-ticket httpd.conf set Access-Control-Allow-Origin to * at server scope,
which covered CGI responses too. The live /usr/local/apache/conf/httpd.conf
sets that header on htdocs and on the trash directories only, never server-wide
and never on cgi-bin. A parked instance has no password, so a wildcard on the
CGI output let any page in the developer's browser read it through the open ssh
tunnel.

The header now sits in the htdocs and trash Directory blocks, with the
Access-Control-Allow-Headers: Range line that accompanies it in the live config,
so range requests on trash files behave the same as in production.

Also replace a tab or newline in the free-text note with a space before it is
written to ports.tsv. The registry is one tab-separated line per ticket, so
those characters appended a malformed row instead of reading back as the note.

Instances parked before this change need ts sync to pick up the new config.

diff --git src/utils/ts/ts src/utils/ts/ts
index 02c9bd36f60..fce5fd87b04 100755
--- src/utils/ts/ts
+++ src/utils/ts/ts
@@ -1,328 +1,337 @@
 #!/bin/bash
 #
 # ts - "ticket sandbox": park a UCSC Genome Browser Redmine ticket as its own
 # frozen browser instance on hgwdev.  refs #37867
 #
 # A per-ticket Apache instance, owned by the developer, bound to a loopback high
 # port, reached by an ssh tunnel (yours or a colleague's - all hgwdev users
 # share the loopback, so anyone with an hgwdev account can tunnel in).  The DB
 # and /gbdb are shared (hg.conf points at hgwdev MySQL; /gbdb is shared NFS) and
 # trash is shared with the live CGIs, so only the *code* is frozen: a full copy
 # of cgi-bin-$USER + htdocs-$USER plus a rewritten hg.conf.  Data is left
 # shared on purpose (freeze the code, share the data).
 #
 # The parked instances and the port registry live under $TS_ROOT, by default
 # $HOME/ticketSandboxes.  On hgwdev, point that at a large local pool (a freeze
 # is a few GB): mkdir /data/home/$USER/ticketSandboxes and symlink it from
 # $HOME, or set TS_ROOT.
 #
 # Subcommands (NNNNN = Redmine ticket number):
 #   create NNNNN [note]   freeze the live sandbox, start an httpd, register it
 #   sync   NNNNN          re-freeze a parked ticket to the current live sandbox
 #   start  NNNNN          start the ticket's httpd (e.g. after a reboot)
 #   stop   NNNNN          stop the ticket's httpd
 #   tunnel NNNNN          open an ssh tunnel and print the browser URL
 #   list                  show all parked tickets and their running status
 #   remove NNNNN          stop the httpd and delete the ticket sandbox
 #
 set -euo pipefail
 
 # --- configuration ----------------------------------------------------------
 ROOT="$(readlink -f "${TS_ROOT:-$HOME/ticketSandboxes}")"   # parked instances live here
 REG="$ROOT/ports.tsv"                        # ticket <TAB> port <TAB> created <TAB> note
 
 TS_USER="${USER:-$(id -un)}"                 # whose live sandbox we freeze
 LIVE_CGI=/usr/local/apache/cgi-bin-$TS_USER
 LIVE_HTDOCS=/usr/local/apache/htdocs-$TS_USER
 HTDOCS_NAME="$(basename "$LIVE_HTDOCS")"     # frozen copy keeps the live name
 SHARED_HTDOCS=/usr/local/apache/htdocs        # sibling ../htdocs the CGIs read for fonts etc.
 SHARED_TRASH=/usr/local/apache/trash          # trash is data: shared with the live CGIs
 BASE_HGCONF=/usr/local/apache/cgi-bin/hg.conf
 
 HTTPD=/usr/sbin/httpd
 MODDIR=modules                               # relative to ServerRoot /etc/httpd
 PORT_BASE=48080
 HGWDEV="${HGWDEV:-hgwdev.gi.ucsc.edu}"
 
 # --- helpers -----------------------------------------------------------------
 die() { echo "ts: $*" >&2; exit 1; }
 
 usage() {   # print the header comment block, minus the shebang
     awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}"
     exit 1
 }
 
 validTkt() { [[ "$1" =~ ^[0-9]+$ ]] || die "ticket must be numeric, got '$1'"; }
 
 tsDir() { echo "$ROOT/$1"; }
 
 regPort() {   # regPort NNNNN -> port, or empty
     [[ -f "$REG" ]] || return 0
     awk -F'\t' -v r="$1" '$1==r{print $2}' "$REG"
 }
 
 portInUse() { # portInUse PORT -> 0 if listening
     local ss; ss="$(command -v ss || echo /usr/sbin/ss)"
     "$ss" -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$1\$"
 }
 
 nextPort() {
     local p="$PORT_BASE"
     while :; do
         if [[ -f "$REG" ]] && awk -F'\t' -v p="$p" '$2==p{f=1} END{exit !f}' "$REG"; then
             p=$((p+1)); continue
         fi
         portInUse "$p" && { p=$((p+1)); continue; }
         echo "$p"; return 0
     done
 }
 
 pidOf() {     # pidOf NNNNN -> pid if httpd.pid exists and process alive
     local dir; dir="$(tsDir "$1")"
     local pf="$dir/httpd.pid"
     [[ -f "$pf" ]] || return 0
     local pid; pid="$(cat "$pf" 2>/dev/null)"
     [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && echo "$pid"
 }
 
 # --- freeze the live sandbox into the ticket dir (used by create and sync) ---
 freeze() {
     local tkt="$1" dir; dir="$(tsDir "$tkt")"
     echo "Freezing live sandbox into $dir ..."
     mkdir -p "$dir/cgi-bin" "$dir/$HTDOCS_NAME" "$dir/logs"
     # Mirror the live directory relationships so CWD-relative reads behave as on
     # the live sandbox.  The CGIs run with CWD = $dir/cgi-bin and read two htdocs:
     #  - DocumentRoot / browser.documentRoot -> $dir/htdocs-$USER (frozen web
     #    content: js, style, description pages)
     #  - ../htdocs (sibling of cgi-bin) -> the SHARED htdocs, for CGI-internal
     #    reads like urw-fonts.  Freeze the code, not the shared static font data.
     [[ -L "$dir/htdocs" || ! -e "$dir/htdocs" ]] || rm -rf "$dir/htdocs"
     ln -sfn "$SHARED_HTDOCS" "$dir/htdocs"
     # trash is data, shared with the live CGIs (custom tracks, uploaded files,
     # sessions, cache): the CGIs write to ../trash relative to their CWD =
     # $dir/cgi-bin, so point that at the live shared trash via a symlink.  The
     # shared trash is world-writable (0777, setgid apache), so the sandbox httpd
     # running as the developer can write it.  Freeze the code, share the data.
     [[ -L "$dir/trash" || ! -e "$dir/trash" ]] || rm -rf "$dir/trash"  # drop any old private trash dir
     ln -sfn "$SHARED_TRASH" "$dir/trash"
     # Full copy (real freeze, not hardlinks).  Exclude the big old/ backup dir.
     # hg.conf is generated fresh below, so exclude it from the copy.
     rsync -a --delete --exclude 'old/' --exclude '/hg.conf' \
         "$LIVE_CGI/"    "$dir/cgi-bin/"
     rsync -a --delete \
         "$LIVE_HTDOCS/" "$dir/$HTDOCS_NAME/"
     # Rewritten, frozen hg.conf:
     #  - include ../cgi-bin/hg.conf -> absolute (avoid self-include loop; the
     #    shared base config is data-side and stays live by design)
     #  - browser.documentRoot -> this ticket's frozen htdocs-$USER
     sed -e 's#^[[:space:]]*include[[:space:]]\+\.\./cgi-bin/hg\.conf#include '"$BASE_HGCONF"'#' \
         -e 's#^[[:space:]]*browser\.documentRoot[[:space:]]*=.*#browser.documentRoot='"$dir"'/'"$HTDOCS_NAME"'#' \
         "$LIVE_CGI/hg.conf" > "$dir/cgi-bin/hg.conf"
     # Per-ticket marker: a densely tiled "RM NNNNN" watermark on the page
     # background, so it stays visible in the gaps around the content and it is
     # obvious which frozen instance you are looking at.  browser.style
     # (cart.c:2993) injects the stylesheet <link>; appended last so it wins.
     makeMarker "$tkt" "$dir"
     printf '\n# ts: per-ticket background marker (refs #37867)\nbrowser.style=/style/tsMarker.css\n' \
         >> "$dir/cgi-bin/hg.conf"
     echo "Freeze complete ($(du -sh "$dir/cgi-bin" "$dir/$HTDOCS_NAME" 2>/dev/null | awk '{print $1}' | paste -sd'+'))."
 }
 
 # --- generate the per-ticket background watermark image + stylesheet ---------
 # Small tile => the label repeats many times across the page, so at least some
 # copies land in the margins that the content does not cover.
 makeMarker() {
     local tkt="$1" dir="$2"
     local png="$dir/$HTDOCS_NAME/style/tsMarker.png"
     local css="$dir/$HTDOCS_NAME/style/tsMarker.css"
     if command -v convert >/dev/null 2>&1; then
         convert -size 150x70 xc:none -gravity center \
             -fill 'rgba(200,40,40,0.28)' -pointsize 17 -weight 700 \
             -annotate 0x0+0+0 "RM $tkt" "$png" 2>/dev/null || true
     fi
     cat > "$css" <<EOF
 /* ts marker for RM #$tkt - refs #37867 */
 body {
     background-image: url("/style/tsMarker.png") !important;
     background-repeat: repeat !important;
     background-attachment: fixed !important;
 }
 EOF
 }
 
 # --- render the per-ticket httpd.conf ----------------------------------------
 writeConf() {
     local tkt="$1" port="$2" dir; dir="$(tsDir "$1")"
     cat > "$dir/httpd.conf" <<EOF
 # Private loopback httpd for RM #$tkt - generated by ts, refs #37867
 ServerName localhost:$port
 ServerRoot "/etc/httpd"
 Listen 127.0.0.1:$port
 
 LoadModule mpm_prefork_module $MODDIR/mod_mpm_prefork.so
 LoadModule unixd_module $MODDIR/mod_unixd.so
 LoadModule authz_core_module $MODDIR/mod_authz_core.so
 LoadModule authz_host_module $MODDIR/mod_authz_host.so
 LoadModule mime_module $MODDIR/mod_mime.so
 LoadModule dir_module $MODDIR/mod_dir.so
 LoadModule alias_module $MODDIR/mod_alias.so
 LoadModule rewrite_module $MODDIR/mod_rewrite.so
 LoadModule headers_module $MODDIR/mod_headers.so
 LoadModule cgi_module $MODDIR/mod_cgi.so
 LoadModule log_config_module $MODDIR/mod_log_config.so
 
 TypesConfig /etc/mime.types
 DirectoryIndex index.html
 
 PidFile "$dir/httpd.pid"
 ErrorLog "$dir/logs/error_log"
 LogFormat "%h %l %u %t \"%r\" %>s %b" common
 CustomLog "$dir/logs/access_log" common
 
 DocumentRoot "$dir/$HTDOCS_NAME"
 ScriptAlias /cgi-bin/ "$dir/cgi-bin/"
 Alias /trash/ "$SHARED_TRASH/"
 
-Header set Access-Control-Allow-Origin "*"
-
+# CORS is scoped to the data directories only, matching the live
+# /usr/local/apache/conf/httpd.conf, which sets it on htdocs and trash and never
+# on cgi-bin.  A parked instance has no password, so a server-wide wildcard would
+# let any page in the developer's browser read CGI output through the open tunnel.
 <Directory "$dir/cgi-bin">
     AllowOverride None
     Options +ExecCGI +FollowSymLinks
     Require all granted
 </Directory>
 <Directory "$dir/$HTDOCS_NAME">
     AllowOverride None
     Options +FollowSymLinks +Includes
+    Header set Access-Control-Allow-Origin "*"
+    Header set Access-Control-Allow-Headers: Range
     Require all granted
 </Directory>
 <Directory "$SHARED_TRASH">
     AllowOverride None
     Options +FollowSymLinks
+    Header set Access-Control-Allow-Origin "*"
+    Header set Access-Control-Allow-Headers: Range
     Require all granted
 </Directory>
 EOF
 }
 
 # --- lifecycle ---------------------------------------------------------------
 startHttpd() {
     local tkt="$1" dir; dir="$(tsDir "$1")"
     [[ -f "$dir/httpd.conf" ]] || die "no httpd.conf for RM $tkt (create it first)"
     if [[ -n "$(pidOf "$tkt")" ]]; then
         echo "RM $tkt httpd already running (pid $(pidOf "$tkt"))."; return 0
     fi
     "$HTTPD" -f "$dir/httpd.conf" -t >/dev/null   # syntax check first
     "$HTTPD" -f "$dir/httpd.conf"
     sleep 1
     local pid; pid="$(pidOf "$tkt")"
     [[ -n "$pid" ]] || { tail -5 "$dir/logs/error_log" >&2; die "httpd failed to start for RM $tkt"; }
     echo "RM $tkt httpd started (pid $pid)."
 }
 
 stopHttpd() {
     local tkt="$1" dir; dir="$(tsDir "$1")"
     local pid; pid="$(pidOf "$tkt")"
     if [[ -z "$pid" ]]; then echo "RM $tkt httpd not running."; return 0; fi
     kill "$pid" 2>/dev/null || true
     sleep 1
     [[ -z "$(pidOf "$tkt")" ]] && echo "RM $tkt httpd stopped." || die "RM $tkt httpd did not stop (pid $pid)"
 }
 
 # --- subcommands -------------------------------------------------------------
 cmd_create() {
     local tkt="$1"; shift || true
     local note="${*:-}"
+    # the registry is one tab-separated line per ticket, so a tab or newline in the
+    # free-text note would append a malformed row instead of reading back as the note
+    note="$(printf '%s' "$note" | tr '\t\n\r' '   ')"
     validTkt "$tkt"
     [[ -d "$LIVE_CGI"    ]] || die "no live sandbox at $LIVE_CGI"
     [[ -d "$LIVE_HTDOCS" ]] || die "no live htdocs at $LIVE_HTDOCS"
     mkdir -p "$ROOT"
     [[ -z "$(regPort "$tkt")" ]] || die "RM $tkt already exists (port $(regPort "$tkt")); use sync/remove"
     local port; port="$(nextPort)"
     freeze "$tkt"
     writeConf "$tkt" "$port"
     printf '%s\t%s\t%s\t%s\n' "$tkt" "$port" "$(date +%Y-%m-%d)" "$note" >> "$REG"
     startHttpd "$tkt"
     echo
     echo "RM $tkt parked on 127.0.0.1:$port"
     echo "  on hgwdev:    curl 'http://127.0.0.1:$port/cgi-bin/hgTracks?db=hg38'"
     echo "  you, remote:  ts tunnel $tkt   ->   http://localhost:$port/cgi-bin/hgTracks"
     echo "  a colleague:  ssh -N -L $port:localhost:$port <user>@$HGWDEV   ->   http://localhost:$port/cgi-bin/hgTracks"
 }
 
 cmd_sync() {
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     local running=""; [[ -n "$(pidOf "$tkt")" ]] && running=1
     [[ -n "$running" ]] && stopHttpd "$tkt"
     freeze "$tkt"
     writeConf "$tkt" "$port"     # regenerate in case template changed
     [[ -n "$running" ]] && startHttpd "$tkt" || echo "RM $tkt re-frozen (httpd was not running)."
 }
 
 cmd_start()  { validTkt "$1"; startHttpd "$1"; }
 cmd_stop()   { validTkt "$1"; stopHttpd "$1"; }
 
 cmd_port() {   # print the port for a ticket (used by the laptop-side ts wrapper)
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     echo "$port"
 }
 
 cmd_tunnel() {
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     cat <<EOF
 Opening ssh tunnel: localhost:$port -> $HGWDEV RM $tkt sandbox.
 While this terminal stays open, point your browser at:
 
     http://localhost:$port/cgi-bin/hgTracks
 
 To let a colleague reach it from their laptop, have them run (with their own
 hgwdev username) and then open the same URL:
 
     ssh -N -L $port:localhost:$port <user>@$HGWDEV
 
 Leave this window running. Ctrl-C closes the tunnel.
 EOF
     exec ssh -N -L "$port:localhost:$port" "$HGWDEV"
 }
 
 cmd_list() {
     [[ -s "$REG" ]] || { echo "No parked tickets."; return 0; }
     printf '%-8s %-6s %-11s %-8s %s\n' RM PORT CREATED STATUS NOTE
     while IFS=$'\t' read -r tkt port created note; do
         [[ -z "$tkt" ]] && continue
         local status="stopped"; [[ -n "$(pidOf "$tkt")" ]] && status="running"
         printf '%-8s %-6s %-11s %-8s %s\n' "$tkt" "$port" "$created" "$status" "$note"
     done < "$REG"
 }
 
 cmd_remove() {
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     stopHttpd "$tkt" || true
     rm -rf "$(tsDir "$tkt")"     # trash is a symlink: removed, not followed
     # drop the registry row
     local tmp; tmp="$(mktemp)"
     awk -F'\t' -v r="$tkt" '$1!=r' "$REG" > "$tmp" && mv "$tmp" "$REG"
     echo "RM $tkt removed."
 }
 
 # --- dispatch ----------------------------------------------------------------
 [[ $# -ge 1 ]] || usage
 sub="$1"; shift || true
 case "$sub" in
     create) [[ $# -ge 1 ]] || usage; cmd_create "$@";;
     sync)   [[ $# -eq 1 ]] || usage; cmd_sync   "$1";;
     start)  [[ $# -eq 1 ]] || usage; cmd_start  "$1";;
     stop)   [[ $# -eq 1 ]] || usage; cmd_stop   "$1";;
     tunnel) [[ $# -eq 1 ]] || usage; cmd_tunnel "$1";;
     port)   [[ $# -eq 1 ]] || usage; cmd_port   "$1";;
     list)   cmd_list;;
     remove) [[ $# -eq 1 ]] || usage; cmd_remove "$1";;
     -h|--help|help) usage;;
     *) die "unknown subcommand '$sub' (try: create sync start stop tunnel list remove)";;
 esac