Skip to content

htsserver: an unauthenticated GET of a directory spins the server forever#724

Merged
xroche merged 6 commits into
masterfrom
fix/website-directory-loop
Jul 26, 2026
Merged

htsserver: an unauthenticated GET of a directory spins the server forever#724
xroche merged 6 commits into
masterfrom
fix/website-directory-loop

Conversation

@xroche

@xroche xroche commented Jul 26, 2026

Copy link
Copy Markdown
Owner

GET /server/ wedges a freshly started htsserver: no session id, no project, nothing set up. fopen() on a directory succeeds on POSIX and every read from it then fails with EISDIR without ever setting the EOF flag, so the while (!feof(fp)) loop that serves a static file in smallserver() never terminates. The session id gates the request body, not GET, and connections come off a single-threaded accept loop, so one request from localhost pins the process at 100% CPU and it never answers anything again. /website/ does the same once a project is registered, and that is where the GUI's "browse mirrored site" link goes.

The serving fopen() now runs only for a regular file, gated on fexist(), the stat + S_ISREG predicate this file already applies to the file-exists: template op. Refusing directories alone would leave FIFOs on the same path, where fopen() blocks for want of a writer and kills the accept loop just as dead, so a whitelist closes the class instead of one instance. Ending the read loop rather than refusing the open is not an alternative: every directory would then come back as a well-formed 200 OK with an empty body, which is worse than a 404 for a mirror browser. One behavior change falls out of it. /dev/null under a mirror root now 404s instead of serving zero bytes, and that seems right: a mirror holds what HTTrack wrote there, and that is never a device node.

The settings loader fopen()s the posted path + projname ahead of every guard, so its read loop stops on ferror() too. The GUI template loop and the fread serving loop got the same treatment, but both sit behind the whitelisted open and I have no probe that reaches them: they are defense in depth with no coverage of their own.

tests/91_webhttrack-directory.test asks for /server/, /website/, a subdirectory and a FIFO, then POSTs a project load whose hts-cache/winprofile.ini is a directory, with a positive control that a real mirror file still serves and an aliveness check after each. Restoring the directory-only refusal fails it on the FIFO alone; restoring while (!feof(fp)) on the settings loader fails it on the project load. The red CodeQL check is pre-existing alert #195, cpp/path-injection on this same fopen, open on master since 2026-07-07 and renumbered by the added lines.

fopen() succeeds on a directory on POSIX and every read from it fails with
EISDIR without ever raising EOF, so smallserver()'s "while (!feof(fp))" serving
loop never terminates. GET /server/ needs no session id and no project to reach
it, and the accept loop is single-threaded, so one unauthenticated local request
wedges WebHTTrack for good.

Refuse a directory before fopen() so the 404 branch answers, rather than only
ending the loop: a loop-only fix would serve every directory as an empty 200.
The two loops fed a client-influenced path also stop on ferror(), since a read
that fails for any other reason spins the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Comment thread src/htsserver.c Fixed
fexist() (htsserver.h) is the stat + S_ISREG predicate this file already uses
for the "file-exists:" template op, so gate the serving fopen() on it rather
than on a fresh negated is_directory(). Refusing only directories still handed
FIFOs to the same code path, where fopen() blocks with no writer and kills the
single-threaded accept loop for good.

Test 91 gains the FIFO case and a POST that loads a project whose
hts-cache/winprofile.ini is a directory: that fopen() sits ahead of every guard,
so it covers the ferror() check on the project-load read loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche xroche closed this Jul 26, 2026
@xroche xroche reopened this Jul 26, 2026
Comment thread src/htsserver.c
&& (fp = fopen(fsfile, "rb"))) {
/* Regular files only: serving a directory or a FIFO never ends. */
if (fsfile[0] && strstr(file, "..") == NULL && fexist(fsfile) &&
(fp = fopen(fsfile, "rb"))) {
xroche and others added 3 commits July 26, 2026 22:13
…ry-loop

Signed-off-by: Xavier Roche <roche@httrack.com>

# Conflicts:
#	tests/Makefile.am
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit f72e7eb into master Jul 26, 2026
21 of 22 checks passed
@xroche
xroche deleted the fix/website-directory-loop branch July 26, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants