Repository: xroche/httrack (2026-07-27T12:41:03Z)

Issue:
A PROPFIND on an exact cache entry crashes proxytrack
`PT_Enumerate()` reports the folder's default document as a zero-length name. The listing loop in `proxytrack_process_DAV_Request()` then evaluates `thisUrl[thisUrlLen - 1]` with an unsigned length of 0, which reads 4 GB past the string, and the process dies. It takes the whole listener down rather than the one connection, so a single unauthenticated request is enough.

Reproduced on 536d751 against a one-entry ARC cache holding `http://example.com/page.html`:

```
curl -X PROPFIND -H 'Depth: 1' http://127.0.0.1:$PORT/webdav/example.com/page.html
```

```
#0  proxytrack_process_DAV_Request (...) at src/proxy/proxytrack.c:750
#1  proxytrack_process_HTTP (...) at src/proxy/proxytrack.c:1140
```

The `/*isDefault */ (thisUrlLen == 0)` argument a few lines below shows the empty name is expected there; only the `thisIsDir` test forgot about it.

The same idiom is at line 857 in `proxytrack_process_HTTP_List()`, unreachable today because `listRequest` is never set to 1.

Found while sweeping #821.



Discussion:

Patch:
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 418cdc11..f575e157 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -262,11 +262,11 @@ jobs:
           # tested nothing: pin the skips, and floor the passes in case the glob empties.
           # footer-overflow and purge-longpath skip on Windows (need a path past MAX_PATH);
           # crange pending #581;
-          # webdav-mime needs a reapable background listener, which MSYS cannot give it;
+          # webdav-default and webdav-mime need a reapable background listener, which MSYS cannot give them;
           # badmtime needs a filesystem that stores an mtime past gmtime's range;
           # single-file ends on a GUI half needing htsserver, which this job does not build;
           # update-304-leak needs a LeakSanitizer build, which MSVC has no equivalent of.
-          expected_skips=" 01_engine-footer-overflow.test 100_local-purge-longpath.test 114_local-update-304-leak.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
+          expected_skips=" 01_engine-footer-overflow.test 100_local-purge-longpath.test 114_local-update-304-leak.test 120_local-proxytrack-webdav-default.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
           # First, or the deadline reads as an unexplained shortfall in the gates below.
           [ "$deadline" -eq 0 ] || { echo "::error::suite did not finish within ${suite_deadline}s"; exit 1; }
           [ "$pass" -ge 90 ] || { echo "::error::only $pass tests passed ($skip skipped)"; exit 1; }
diff --git a/src/proxy/proxytrack.c b/src/proxy/proxytrack.c
index c34afbf8..e74feddb 100644
--- a/src/proxy/proxytrack.c
+++ b/src/proxy/proxytrack.c
@@ -748,7 +748,8 @@ static PT_Element proxytrack_process_DAV_Request(PT_Indexes indexes,
             const char *thisUrl = list[i];
             const char *mimeType = "application/octet-stream";
             unsigned int thisUrlLen = (unsigned int) strlen(thisUrl);
-            int thisIsDir = (thisUrl[thisUrlLen - 1] == '/') ? 1 : 0;
+            /* the folder's default document is enumerated as an empty name */
+            int thisIsDir = (hts_lastchar(thisUrl) == '/') ? 1 : 0;
 
             /* Item URL */
             StringRoom(itemUrl,
@@ -853,8 +854,7 @@ static PT_Element proxytrack_process_HTTP_List(PT_Indexes indexes,
     for(isDir = 1; isDir >= 0; isDir--) {
       for(i = 0; list[i] != NULL; i++) {
         char *thisUrl = list[i];
-        unsigned int thisUrlLen = (unsigned int) strlen(thisUrl);
-        int thisIsDir = (thisUrl[thisUrlLen - 1] == '/') ? 1 : 0;
+        int thisIsDir = (hts_lastchar(thisUrl) == '/') ? 1 : 0;
 
         if (thisIsDir == isDir) {
           if (isDir)


Test patch:
diff --git a/tests/120_local-proxytrack-webdav-default.test b/tests/120_local-proxytrack-webdav-default.test
new file mode 100755
index 00000000..f36f9791
--- /dev/null
+++ b/tests/120_local-proxytrack-webdav-default.test
@@ -0,0 +1,121 @@
+#!/bin/bash
+#
+# A PROPFIND whose path is an exact cache entry enumerates that entry's default
+# document under an empty name; proxytrack used to index one byte before it and
+# die on the whole listener.
+
+set -euo pipefail
+
+: "${top_srcdir:=..}"
+# shellcheck source=tests/testlib.sh
+. "$top_srcdir/tests/testlib.sh"
+
+python=$(find_python) || {
+    echo "python3 missing, skipping"
+    exit 77
+}
+command -v curl >/dev/null 2>&1 || {
+    echo "curl missing, skipping"
+    exit 77
+}
+# MSYS cannot reap a native listener, and the orphan wedges the suite (#595).
+if is_windows; then
+    echo "windows: cannot reap a backgrounded proxytrack, skipping"
+    exit 77
+fi
+
+dir=$(mktemp -d)
+ptpid=
+cleanup() {
+    stop_server "$ptpid"
+    rm -rf "$dir"
+}
+trap 'set +e; cleanup' EXIT
+trap 'set +e; cleanup; exit 1' INT TERM HUP
+
+printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\n' >"$dir/hdr"
+printf 'hello' >"$dir/body"
+alen=$(($(wc -c <"$dir/hdr") + $(wc -c <"$dir/body")))
+{
+    printf 'filedesc://t.arc 0.0.0.0 20250101000000 text/plain 200 - - 0 t.arc 9\n'
+    printf '2 0 test\n'
+    printf '\n\n'
+    printf 'http://example.com/page.html 0.0.0.0 20250101000000 text/html 200 - - 0 t.arc %d\n' "$alen"
+    cat "$dir/hdr" "$dir/body"
+} >"$dir/in.arc"
+
+freeport() {
+    "$python" -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
+}
+proxyport=$(freeport)
+icpport=$(freeport)
+
+proxytrack "127.0.0.1:$proxyport" "127.0.0.1:$icpport" "$dir/in.arc" >"$dir/pt.log" 2>&1 &
+ptpid=$!
+waited=0
+until grep -qE "HTTP Proxy installed on|Unable to (initialize a temporary server|create the server)" "$dir/pt.log"; do
+    kill -0 "$ptpid" 2>/dev/null || {
+        echo "FAIL: proxytrack exited before listening"
+        cat "$dir/pt.log"
+        exit 1
+    }
+    test "$waited" -lt 50 || {
+        echo "FAIL: proxytrack never announced its listen port"
+        exit 1
+    }
+    sleep 0.1
+    waited=$((waited + 1))
+done
+grep -q "HTTP Proxy installed on" "$dir/pt.log" || {
+    echo "FAIL: proxytrack failed to bind"
+    cat "$dir/pt.log"
+    exit 1
+}
+
+propfind() {
+    curl -s -i --max-time 30 -X PROPFIND -H "Depth: 1" \
+        "http://127.0.0.1:$proxyport/webdav/example.com/$1"
+}
+
+resp=$(propfind page.html) || {
+    echo "FAIL: PROPFIND on an exact cache entry did not answer"
+    cat "$dir/pt.log"
+    exit 1
+}
+grep -q '^HTTP/1\.[01] 207 ' <<<"$resp" || {
+    echo "FAIL: expected 207 Multi-Status"
+    printf '%s\n' "$resp"
+    exit 1
+}
+grep -q '<displayname>Default Document for the Folder</displayname>' <<<"$resp" || {
+    echo "FAIL: the default document of the entry was not listed"
+    printf '%s\n' "$resp"
+    exit 1
+}
+# The name alone would still pass on a truncated or garbage URL, so pin the
+# href: the default document is the collection path plus an empty name.
+grep -q '<href>/webdav/example\.com/page\.html/</href>' <<<"$resp" || {
+    echo "FAIL: the default document was listed under the wrong href"
+    printf '%s\n' "$resp"
+    exit 1
+}
+responses=$(grep -c '<response ' <<<"$resp" || true)
+test "$responses" -eq 2 || {
+    echo "FAIL: expected the root and its default document, got $responses responses"
+    printf '%s\n' "$resp"
+    exit 1
+}
+
+# The crash killed the process, so a second request is the liveness proof.
+resp=$(propfind "") || {
+    echo "FAIL: proxytrack died serving the previous PROPFIND"
+    cat "$dir/pt.log"
+    exit 1
+}
+grep -q '^HTTP/1\.[01] 207 ' <<<"$resp" || {
+    echo "FAIL: expected 207 Multi-Status on the follow-up listing"
+    printf '%s\n' "$resp"
+    exit 1
+}
+
+echo "OK: PROPFIND on an exact cache entry lists its default document and survives"
diff --git a/tests/tests-list.mk b/tests/tests-list.mk
index 1437a962..c87bfcce 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -195,6 +195,7 @@ TESTS += 96_local-refetch-keep.test
 TESTS += 97_local-warc-update-keep.test
 TESTS += 98_local-warc-segments.test
 TESTS += 99_local-robots-error.test
+TESTS += 120_local-proxytrack-webdav-default.test
 TESTS += 107_local-bak-collision.test
 TESTS += 108_engine-refetch-backup.test
 TESTS += 122_local-warc-revisit-headers.test