2e48cc57a9fd1f065c439b14df8ddf805901e391
braney
  Tue Aug 11 11:14:20 2026 -0700
udc: reject a ".." path component when turning a remote URL into a cache path, refs #38056

udc builds its on-disk cache path out of the remote URL. qEscaped deliberately leaves
'.' and '/' unescaped so cached names stay readable, and longDirHash only rewrites
components that are too long for the filesystem, so a ".." in the URL survives all
the way into the cache path. makeDirsOnPath then creates those directories and we
write bitmap and sparseData files into them, outside the cache root, as the web server
user. A custom track or hub bigDataUrl gets here with no login.

Demonstrated against an unpatched build with a local server standing in for a hostile
hub host, since the escape needs a server that answers a request whose path contains
"../". Opening http://host/a/../../../../../ESCAPED/pwn.bb created ESCAPED/pwn.bb five
levels above the cache root and wrote the server's content into sparseData inside it.
That is why the log review found no successful attempt: every one seen in the wild
pointed at a legitimate third-party host, and those normalize the path and refuse. An
attacker pointing at their own host would have succeeded.

The check goes in udcPathAndFileNames rather than in udcParseUrl, which is where the
draft on the ticket suggested it. That function is the one place a URL becomes a
filesystem path, and it covers both callers that build cache paths. udcParseUrl is
also used by udcIsLocal, which is only a predicate and is called from linefile.c,
hdb.c, liftOver.c and elsewhere to decide how to open something; aborting in there
would turn a question into a failure.

Only the ".." component is rejected, so names that merely begin with dots, such as
"..foo" or "a..b", keep working. Cache paths for ordinary URLs are byte for byte what
they were, which matters because a change there would invalidate every cached file on
every node. Verified by diffing the derived paths for a set of URLs across the change:
the only lines that differ are the malicious ones.

Escaping '.' in qEscaped would also have closed this, but it would rewrite every
existing cache path and force a full re-download for no gain over rejecting "..".

diff --git src/lib/udc.c src/lib/udc.c
index 9aa79f10fed..4049b899bb4 100644
--- src/lib/udc.c
+++ src/lib/udc.c
@@ -1255,30 +1255,41 @@
 
     name = ptr + 1;
     ptr = strchr(name, '/');
     }
 
 addElementToDy(dy, maxLen, name);
 
 return dyStringCannibalize(&dy);
 }
 
 void udcPathAndFileNames(struct udcFile *file, char *cacheDir, char *protocol, char *afterProtocol)
 /* Initialize udcFile path and names */
 {
 if (cacheDir==NULL)
     return;
+/* SECURITY (refs #38056): this is where a remote URL becomes a local path, so it is the
+ * place to make sure the URL cannot climb out of the cache.  qEscaped deliberately leaves
+ * '.' and '/' alone so cached names stay readable, and longDirHash only rewrites
+ * over-long components, so a ".." in the URL survives all the way into cacheDir.  From
+ * there makeDirsOnPath would create directories outside the cache root and we would write
+ * bitmap and sparseData files into them.  A custom track or hub bigDataUrl reaches here,
+ * so this is anonymous.  Reject the ".." path component; note that names merely starting
+ * with dots, like "..foo", are fine and must keep working. */
+if (startsWith("../", afterProtocol) || stringIn("/../", afterProtocol) != NULL ||
+    endsWith(afterProtocol, "/..") || sameString(afterProtocol, ".."))
+    errAbort("Illegal '..' in the path of a remote URL: %s://%s", protocol, afterProtocol);
 char *hashedAfterProtocol = longDirHash(cacheDir, afterProtocol);
 int len = strlen(cacheDir) + 1 + strlen(protocol) + 1 + strlen(hashedAfterProtocol) + 1;
 file->cacheDir = needMem(len);
 safef(file->cacheDir, len, "%s/%s/%s", cacheDir, protocol, hashedAfterProtocol);
 verbose(4, "UDC dir: %s\n", file->cacheDir);
 
 /* Create file names for bitmap and data portions. */
 file->bitmapFileName = fileNameInCacheDir(file, bitmapName);
 file->sparseFileName = fileNameInCacheDir(file, sparseDataName);
 file->redirFileName = fileNameInCacheDir(file, redirName);
 file->resolvedFileName = fileNameInCacheDir(file, resolvedName);
 }
 
 static long long int udcSizeAndModTimeFromBitmap(char *bitmapFileName, time_t *retTime)
 /* Look up the file size from the local cache bitmap file, or -1 if there