Compare commits

...

10 Commits

Author SHA1 Message Date
Xavier Roche
e2088c6947 Reject exactly INT32_MAX and cover the accept path
An adversarial review of the in-memory bound found an off-by-one: the guard used
`> INT32_MAX`, but at `size + bufl == INT32_MAX` the unknown-length reallocs
compute `(int) size + bufl + 1 == INT32_MAX + 1`, a signed-overflow UB (it fails
safe because the negative width becomes a huge size_t and realloc returns NULL,
but it still aborts under UBSan). Use `>= INT32_MAX`.

The self-test gains a boundary case (that exact size must be refused, the teeth
for the off-by-one) and an accept case (a normal small size must not be), so a
stray `>` or a refuse-everything guard both fail it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-11 21:35:16 +02:00
Xavier Roche
331db32d26 Bound the in-memory receive buffer against a hostile Content-Length
http_xfread1 buffers a non-disk (hypertext) response whole in RAM, sized
straight from the server's Content-Length, or grown without limit for a
chunked/unknown-length stream. The reads there already assume the buffer fits a
32-bit index (the "no 4GB html" comment and the int offsets), but nothing
enforced it: a hostile text/html response with a multi-GB Content-Length drove
an unbounded allocation on the first fetch. Reject an in-memory size that would
exceed INT32_MAX and return a clean error. A resource httrack holds whole in RAM
is hypertext and small; binary content (video, archives, ...) streams to disk
and never reaches this branch, so nothing legitimate is refused.

This is the initial-fetch companion to #535, which bounded the same allocation
on the 206-resume path. Self-test -#test=xfread-limit + 01_engine-xfread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-11 21:18:38 +02:00
Xavier Roche
ef40277c24 Bound the in-memory 206-resume allocation to a 32-bit size (#535)
CodeQL flagged the memory-branch resume buffer as an uncontrolled allocation
size: malloct(resume + 1 + totalsize) takes an attacker-controlled
Content-Length. #534 guarded the int64 add against overflow but left the
allocation itself unbounded, and the (size_t) cast still truncated on a 32-bit
build. Cap the buffer at INT32_MAX -- a resource held whole in RAM is far
smaller -- and route an over-large or overflowing size to the existing
drop-and-refetch path. Test 48_local-crange-memresume already exercises that
path (its INT64_MAX Content-Length now trips the tighter bound).

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:03:07 +02:00
Xavier Roche
797acecfd2 Harden Content-Range integer handling against hostile and oversize values (#534)
* Harden Content-Range integer handling against hostile and oversize values

Two integer-safety defects in the Content-Range / size path, both reachable
from a hostile or buggy server and abort-worthy under the project's UBSan/
OSS-Fuzz CI (a sanitizer abort is a real bug, not flakiness).

C2 (signed-overflow UB). The signed int64 crange fields are parsed straight
from the header with no validation, so a crafted Content-Range drives the
crange +/- 1 range checks past the int64 extremes. Clamp any negative field to
the all-zero "no range" sentinel at parse time (this covers the parse-side
crange-1 fallback and the 200->206 fake-range check), and rewrite the 206
resume check crange_end + 1 == crange as crange_end == crange - 1, which is
guarded by the preceding crange > 0 so an INT64_MAX end cannot overflow.

C4 (off_t truncated to int). The sizehack read the on-disk size into a 32-bit
int, truncating it for files >= 2 GiB, so a same-size match could force a 304
that records the wrong size (cache/disk metadata desync, never a truncated
file). Widen to off_t like every sibling call site.

Tests: -#test=crange drives the parser (UBSan aborts pre-fix on the INT64_MIN
fallback); 47_local-crange-overflow drives the 206-resume path with an
INT64_MAX Content-Range (UBSan aborts pre-fix at the crange+1 check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Also guard the in-memory 206-resume buffer size against a hostile Content-Length

An adversarial review of the crange hardening surfaced a third overflow in the
same 206-resume path: the in-memory branch sizes its buffer as
`resume + 1 + totalsize`, and `totalsize` (Content-Length) is attacker-
controlled and unclamped. A resume answered with a 206 that lies `text/html`
(so the resume buffers in memory) and a matching INT64_MAX Content-Length drives
the add to INT64_MAX + 1 — a signed-overflow UBSan abort, reachable from a single
hostile response. Reject a Content-Length that would overflow the size, drop the
partial, and refetch from scratch (the retry restarts clean).

Test 48_local-crange-memresume drives it end to end (UBSan aborts pre-fix at
`alloc_mem += totalsize`); 01_engine-crange gains a `-5-10/20` case exercising
the clamp's independent per-field negativity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:04:47 +02:00
Xavier Roche
8237c3ed2b Don't finalize a resume's partial as complete on an out-of-protocol 304 (#533)
* Don't finalize a resume's partial as complete on an out-of-protocol 304

A resume request carries Range plus If-Unmodified-Since/If-Match, to which a
conformant server answers 206/200/412, never 304. The 304 handler's
"if-unmodified-since hack" re-accepted any existing on-disk file and recorded
it complete at its current byte count, so a broken or hostile 304 finalized
the partial as the whole file. In the repro the partial was never committed,
leaving the mirror claiming a complete file that is absent.

Capture whether the server itself sent the 304 (the size-match hacks force
NOT_MODIFIED only after confirming completeness). When it did on a Range
resume, drop the partial and its temp-ref and refetch, mirroring the
unusable-206 restart. Non-resume validations (range_req_size == 0) and the
416/sizehack completions are unaffected.

Test 46_local-update-304-resume drives it end to end: a stalled first fetch
leaves a partial + temp-ref, the resume's Range gets a bogus 304, and httrack
must recover the whole file rather than trust the 304.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Test 46: assert the resume's Range actually drew the 304

The size and request-count checks alone could pass a wrong-fix that silently
switched to a fresh re-crawl instead of handling the 304 on the resume path.
Mark when the server 304s a Range request and assert it fired, so recovery is
proven to go through the resume rather than around it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:27:57 +02:00
Xavier Roche
75b815e1fb tests: drop GNU-only uname -o so make check is quiet on macOS (#532)
The crawl harness built the test user-agent with uname -omrs. BSD/macOS
uname has no -o flag, so every crawl printed "uname: illegal option -- o"
to stderr and emitted an empty OS field. -s already names the kernel
(Linux/Darwin), making -o redundant; drop it for a portable uname -mrs.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:57:34 +02:00
Xavier Roche
8d02e60907 Bound test 19's dead-address connects so it is fast on macOS (#531)
19_local-connect-fallback pins dead loopback IPs (127.0.0.2/.3) before the live
one to exercise address fallback. On Linux those refuse instantly (all of
127.0.0.0/8 is loopback); macOS has only 127.0.0.1, so the connects block to
--timeout, and default retries replay the whole crawl, so the exhaustion case
took ~2 minutes. It passed but dominated the macOS suite once the rest runs in
parallel. Drop --timeout to 5s (bounds the last candidate's stall; the earlier
ones already fall back at min(timeout,10)) and set --retries=0 (no ×3 replay).
Linux is unchanged: the refused connects never reach the timeout.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:18:18 +02:00
Xavier Roche
e70607e70c Run the test suite in parallel in CI (make check -j) (#528)
* Run the test suite in parallel in CI (make check -j)

The suite already builds under automake's parallel test harness (no
serial-tests), but every make check in CI ran it serially, so the ~90 tests
executed one at a time. Each crawl test spawns its own Python server on an
ephemeral port into a private mktemp dir, so nothing is shared across tests and
-j never contends on a port or a fixture; the only cross-test file
(check-network_sh.cache) is idempotent and gated behind the online tests.

Pass -j (nproc, or hw.ncpu on macOS) to every make check. Measured 3:26 -> 0:37
on a 12-core box, stable across runs including 2x oversubscription. Past a few
cores the wall time floors on the single longest test (bigcrawl ~24s), so this
is the whole win short of trimming those long poles.

The Debian build already parallelizes its packaged test pass: debhelper's
dh_auto_test emits `make -jN check` from DEB_BUILD_OPTIONS parallel=N, which the
CI deb job sets to nproc and Debian buildds set from their job count. Nothing to
change in debian/rules (Policy requires honoring DEB_BUILD_OPTIONS, not
hard-coding -j).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* AGENTS.md: note new tests auto-spread across -j workers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Oversubscribe make check to 2x cores (capped 16) in CI

The suite is sleep-heavy: bigcrawl self-paces over ~365 files, resume-overlap
polls and waits, and the trickle/delayed tests sleep server-side. On a
core-constrained runner those idle cores go to waste at -j=cores. Running twice
as many test jobs as cores lets a ready test use a core while another sleeps.

Measured on a 3-core cpu-set (the macOS/Linux runner regime): -j3 79.5s, -j6
(2x) 43.1s (1.85x), then diminishing returns to the ~34s wall-clock floor set
by the longest single test (-j9 39.9s, -j16 37.3s). Stable across repeats, all
tests pass. The cap keeps big machines from spawning 100+ server+httrack pairs
for no gain once floor-bound.

Only the make check steps change; the build stays at -j nproc (CPU-bound, where
oversubscription only hurts), and the Debian job keeps parallel=nproc since that
knob drives both its build and test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Keep macOS at one test job per core (loopback drops under 2x)

2x oversubscription overloads macOS's loopback: six httrack processes each at
-c8 push ~48 concurrent connections, the stack drops a couple, and
36_local-bigcrawl under-fetches (expected 361 files, got 359). This is the same
macOS-only flake #527 fixed for the error count, now surfaced on the exact file
count. Linux tolerates 2x (all Linux jobs green, stable in local sim); macOS
does not, so pin it to one job per core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Run macOS test suite serially (parallel flakes bigcrawl on loopback)

Parallel make check on macOS runs 36_local-bigcrawl's -c8 crawl alongside the
other crawl tests; the aggregate load overloads macOS's loopback, which drops
fetches, so bigcrawl under-fetches and its exact file count fails (saw 359 and
341 vs 361, at both 2x and one-per-core). This is the macOS-only loopback drop
#527 handled for the error count, now on the file count. Linux does not exhibit
it. Pin macOS to a serial run until bigcrawl tolerates transient drops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Isolate bigcrawl in a second pass on macOS to parallelize the rest

macOS was the CI bottleneck (~460s serial, the ~8min total): its loopback drops
fetches when bigcrawl's sustained -c8 crawl competes with other crawls, so a
parallel make check flaked bigcrawl's exact file count. Rather than keep the
whole macOS suite serial, run everything except bigcrawl in parallel (2x cores,
capped 16), then bigcrawl alone in a second pass, which is the serial-safe
condition green on master. Linux still runs the full suite in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:18:04 +02:00
Xavier Roche
cada6a1ae3 Meter -M against received volume, not saved bytes (#530)
* Meter -M against received volume, not saved bytes (#520)

-M ("maximum overall size that can be uploaded/scanned") enforced its
cap against HTS_STAT.stat_bytes, which counts only saved HTTP 200 bodies
(minus robots.txt). On crawls dominated by redirects, error bodies, or
plugin-written files the counter lags the volume actually pulled off the
network, so the cap barely bit — #77's reporter saw ~12.5 MB on disk
while "Bytes saved" hovered near a 3 MB cap.

Enforce against HTS_STAT.HTS_TOTAL_RECV instead: the true received
volume, incremented on every recv()/fread and monotonic, matching the
option's "scanned" wording. The #519 smooth-stop/grace/hard-abort
machinery is unchanged; only the metered quantity moves, and it stays
monotonic so the sticky size-branch invariant still holds. Received is
credited live per-chunk rather than at transfer completion, so the cap
also bounds overshoot slightly tighter.

Test 45_local-maxsize-recv drives a fixture of large 404 bodies
(received but never saved): the cap fires though only the index lands on
disk. It fails against master (stat_bytes never reaches the cap, all
links fetched) and passes with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Test 45: bound saved bytes so the cap-meter differential is pinned

The single --log-found assertion tripped on "some counter crossed the
cap"; its master-fails differential silently depended on 404 bodies not
being saved. Add --max-mirror-bytes 100000 (observed 573 B) so the test
asserts the cap fired while almost nothing landed on disk, which only the
received-volume meter can do. A build that saved the 404 bodies would
push stat_bytes past the cap and the mirror past the bound, failing here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:17:30 +02:00
Xavier Roche
145ea4769f Make test 31 fail loudly when the java plugin doesn't load (#529)
31_local-javaclass verifies the java plugin parses a .class constant pool
by asserting a resource named only inside Foo.class gets crawled. When the
plugin fails to load, that assertion just reports hello.gif "not found",
with no hint the plugin was the cause -- which is why an intermittent
local failure sat uninvestigated.

The failure mode is a shadowing system libhtsjava: on a box with httrack
installed, if the soname httrack dlopens is absent from the fresh build
but present in /usr/lib, the loader picks up the system plugin (a
mismatched, non-instrumented build) and it silently parses nothing. It
looked ASan-specific only because that was the build in use; clean CI
runners have no system plugin, so they never hit it. #475 aligned the
dlopen name with the build's own soname, closing the gap.

Assert the plugin's launched-banner suffix ("+libhtsjava") first, so a
load failure trips there with a clear message instead of the opaque
hello.gif miss. The "+libhtsjava" prefix survives future soname bumps.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:33:45 +02:00
17 changed files with 764 additions and 30 deletions

View File

@@ -55,7 +55,9 @@ jobs:
run: make -j"$(nproc)"
- name: Test
run: make check
run: |
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()
@@ -99,7 +101,8 @@ jobs:
sudo find /usr/bin /usr/local/bin -maxdepth 1 -name 'python3*' \
-exec mv {} {}.hidden \;
! command -v python3
make check
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()
@@ -133,7 +136,15 @@ jobs:
run: make -j"$(sysctl -n hw.ncpu)"
- name: Test
run: make check
# bigcrawl's sustained -c8 crawl drops fetches on macOS's loopback when
# it competes with other crawls, flaking its exact file count (the #527
# macOS drop). Run everything else in parallel, then bigcrawl alone (its
# serial-safe condition). Linux tolerates the full parallel run.
run: |
jobs=$(( $(sysctl -n hw.ncpu) * 2 )); [ "$jobs" -le 16 ] || jobs=16
rest=$(cd tests && ls *.test | grep -v '^36_local-bigcrawl\.test$' | tr '\n' ' ')
make check -j"$jobs" TESTS="$rest"
make check TESTS=36_local-bigcrawl.test
- name: Print the test log on failure
if: failure()
@@ -171,7 +182,9 @@ jobs:
run: make -j"$(nproc)"
- name: Test
run: make check
run: |
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()
@@ -226,7 +239,9 @@ jobs:
env:
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1:halt_on_error=1:strict_string_checks=1:malloc_fill_byte=202:max_malloc_fill_size=2147483647:free_fill_byte=203:max_free_fill_size=2147483647
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: make check
run: |
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()
@@ -272,7 +287,8 @@ jobs:
# 01_engine-* only; zlib-dependent self-tests are named 01_zlib-* and
# skipped here (uninstrumented libz floods MSan with false positives).
tests="$(cd tests && ls 01_engine-*.test | tr '\n' ' ')"
make check TESTS="$tests"
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs" TESTS="$tests"
- name: Print the test log on failure
if: failure()
@@ -347,7 +363,9 @@ jobs:
run: make -j"$(nproc)"
- name: Test
run: make check
run: |
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()

View File

@@ -6,8 +6,19 @@ the operational checklist: toolchain, invariants, and how to ship a change.
## Build & test
- Fresh clone first: `git submodule update --init src/coucal`
- `./bootstrap` (regenerates `configure` via `autoreconf`; needs autoconf,
automake, libtool), then `bash configure && make && make check`. Or run
`sh build.sh` to do bootstrap + configure + make in one shot.
automake, libtool), then `bash configure && make -j"$(nproc)" && make check
-j"$(nproc)"`. Always pass `-j` to `make check`: the suite runs under
automake's parallel harness and each crawl test binds its own ephemeral-port
server, so `-j` never contends and a multi-minute serial run drops to
seconds. A new `.test` added to `$(TESTS)` is scheduled onto a free worker
automatically; only a test slower than the current longest raises the floor.
On a few-core Linux box, `-j` at 2x the core count is faster still: the tests
spend much of their wall time asleep (server trickles, httrack self-pacing),
so an idle core covers a sleeping one. CI uses `min(2*cores, 16)`. macOS runs
36_local-bigcrawl alone in a second pass: its sustained `-c8` crawl overloads
the macOS loopback when it competes with other crawls and flakes its exact
file count (Linux tolerates the full parallel run).
Or run `sh build.sh` to do bootstrap + configure + make in one shot.
## Hard invariants
- **Generated autotools files are NOT in git.** `configure`, every

View File

@@ -3512,6 +3512,11 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
continue;
}
// The server really sent 304 here; the *-hacks below force
// NOT_MODIFIED only after confirming the file is complete.
const hts_boolean server_sent_304 =
(back[i].r.statuscode == HTTP_NOT_MODIFIED);
/*
Solve "false" 416 problems
*/
@@ -3606,7 +3611,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
if (back[i].r.statuscode == HTTP_OK && !back[i].testmode) { // 'OK'
if (!is_hypertext_mime(opt, back[i].r.contenttype, back[i].url_fil)) { // not HTML
if (strnotempty(back[i].url_sav)) { // target found
int size = fsize_utf8(back[i].url_sav); // target size
off_t size = fsize_utf8(back[i].url_sav);
if (size >= 0) {
if (back[i].r.totalsize == size) { // same size!
@@ -3701,6 +3706,21 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
}
}
// Out-of-protocol 304 to a Range resume: the file is still
// partial, so drop it and refetch instead of trusting it.
if (server_sent_304 && back[i].range_req_size > 0) {
url_savename_refname_remove(opt, back[i].url_adr,
back[i].url_fil);
UNLINK(back[i].url_sav);
deletehttp(&back[i].r);
back[i].r.soc = INVALID_SOCKET;
back[i].r.statuscode = STATUSCODE_NON_FATAL;
strcpybuff(back[i].r.msg,
"Bogus 304 on resume, restarting");
back[i].status = STATUS_READY;
back_set_finished(sback, i);
}
/* sinon, continuer */
/* if (back[i].r.soc!=INVALID_SOCKET) { // ok récupérer body? */
// head: terminé
@@ -3819,7 +3839,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
const hts_boolean range_ok =
back[i].r.crange > 0 && resume >= 0 &&
resume <= (LLint) sz &&
back[i].r.crange_end + 1 == back[i].r.crange &&
back[i].r.crange_end == back[i].r.crange - 1 &&
(back[i].r.totalsize < 0 ||
back[i].r.totalsize ==
back[i].r.crange_end - resume + 1);
@@ -3888,11 +3908,20 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
if (fp) {
LLint alloc_mem = resume + 1;
if (back[i].r.totalsize >= 0)
// Bound the in-memory buffer to a 32-bit size (real
// in-RAM resources are far smaller); a hostile
// Content-Length that would overflow the add or the
// (size_t) cast is dropped and refetched instead.
if (back[i].r.totalsize > INT32_MAX - alloc_mem) {
url_savename_refname_remove(opt, back[i].url_adr,
back[i].url_fil);
UNLINK(back[i].url_sav);
alloc_mem = -1;
} else if (back[i].r.totalsize >= 0)
alloc_mem += back[i].r.totalsize; // AJOUTER RESTANT!
if (deleteaddr(&back[i].r)
&& (back[i].r.adr =
(char *) malloct((size_t) alloc_mem))) {
if (alloc_mem >= 0 && deleteaddr(&back[i].r) &&
(back[i].r.adr =
(char *) malloct((size_t) alloc_mem))) {
back[i].r.size = resume;
if (back[i].r.totalsize >= 0)
back[i].r.totalsize += resume; // -> full size
@@ -4178,10 +4207,13 @@ static int back_maxtime_grace(const int maxtime) {
No floor (unlike the time grace): a size overrun should abort promptly. */
static LLint back_maxsize_grace(const LLint maxsite) { return maxsite / 10; }
/* Which cap has overrun its grace and must hard-stop the mirror (#77, #481). */
/* Which cap has overrun its grace and must hard-stop the mirror (#77, #481).
-M measures received volume (HTS_TOTAL_RECV), not saved 200-only stat_bytes
which undercounts redirect/error-heavy crawls (#520). */
static hts_mirror_limit back_mirror_limit(httrackp *opt) {
if (opt->maxsite > 0 && HTS_STAT.stat_bytes >= opt->maxsite &&
HTS_STAT.stat_bytes - opt->maxsite >= back_maxsize_grace(opt->maxsite))
if (opt->maxsite > 0 && HTS_STAT.HTS_TOTAL_RECV >= opt->maxsite &&
HTS_STAT.HTS_TOTAL_RECV - opt->maxsite >=
back_maxsize_grace(opt->maxsite))
return HTS_MIRROR_LIMIT_SIZE;
if (opt->maxtime > 0) {
const TStamp elapsed = time_local() - HTS_STAT.stat_timestart;
@@ -4195,7 +4227,7 @@ static hts_mirror_limit back_mirror_limit(httrackp *opt) {
int back_checkmirror(httrackp *opt) {
/* request a smooth stop the first time each cap is reached */
if (opt->maxsite > 0 && HTS_STAT.stat_bytes >= opt->maxsite &&
if (opt->maxsite > 0 && HTS_STAT.HTS_TOTAL_RECV >= opt->maxsite &&
!opt->state.stop) {
hts_log_print(opt, LOG_ERROR,
"More than " LLintP

View File

@@ -1586,7 +1586,7 @@ void treathead(t_cookie * cookie, const char *adr, const char *fil, htsblk * ret
a = strchr(rcvd + p, '/');
if (a != NULL) {
a++;
if (sscanf(a, LLintP, &retour->crange) == 1) {
if (sscanf(a, LLintP, &retour->crange) == 1 && retour->crange >= 0) {
retour->crange_start = 0;
retour->crange_end = retour->crange - 1;
} else {
@@ -1594,6 +1594,12 @@ void treathead(t_cookie * cookie, const char *adr, const char *fil, htsblk * ret
}
}
}
// A valid Content-Range has no negative field; reject hostile values so
// the crange +/- 1 arithmetic downstream cannot sign-overflow (UB).
if (retour->crange_start < 0 || retour->crange_end < 0 ||
retour->crange < 0) {
retour->crange_start = retour->crange_end = retour->crange = 0;
}
}
} else if ((p = strfield(rcvd, "Connection:")) != 0) {
char *a = rcvd + p;
@@ -1998,6 +2004,15 @@ LLint http_xfread1(htsblk * r, int bufl) {
if (bufl > 0) {
if (!r->is_write) { // stocker en mémoire
// In-memory content must fit a 32-bit index (allocs below add 1, reads
// use int offsets): reject a hostile Content-Length or endless stream.
const LLint inmem_want =
(r->totalsize >= 0) ? r->totalsize : (r->size + bufl);
if (inmem_want >= INT32_MAX) {
r->statuscode = STATUSCODE_INVALID;
strcpybuff(r->msg, "In-memory content too large");
return READ_ERROR;
}
if (r->totalsize >= 0) { // totalsize déterminé ET ALLOUE
if (r->adr == NULL) {
r->adr = (char *) malloct((size_t) r->totalsize + 1);

View File

@@ -1310,6 +1310,82 @@ static int st_headerlong(httrackp *opt, int argc, char **argv) {
return 0;
}
/* http_xfread1 must refuse an in-memory buffer whose size would exceed a 32-bit
index (hostile Content-Length or endless stream) rather than allocate it.
The guard returns before any socket read, so no real connection is needed. */
static int st_xfread_limit(httrackp *opt, int argc, char **argv) {
htsblk r;
(void) opt;
(void) argc;
(void) argv;
// Content-Length just over 2 GiB.
memset(&r, 0, sizeof(r));
r.soc = INVALID_SOCKET;
r.totalsize = (LLint) INT32_MAX + 1;
printf("bylen: refused=%d adr=%s msg=%s\n",
http_xfread1(&r, 8192) == READ_ERROR, r.adr != NULL ? "alloc" : "null",
r.msg);
if (r.adr != NULL)
freet(r.adr);
// Unknown length, buffer already at the limit: the next read would exceed it.
memset(&r, 0, sizeof(r));
r.soc = INVALID_SOCKET;
r.totalsize = -1;
r.size = (LLint) INT32_MAX;
printf("bygrow: refused=%d adr=%s msg=%s\n",
http_xfread1(&r, 8192) == READ_ERROR, r.adr != NULL ? "alloc" : "null",
r.msg);
if (r.adr != NULL)
freet(r.adr);
// Exactly at the 2 GiB index (size + bufl == INT32_MAX): must also be
// refused, since the reallocs below add 1 (a `> INT32_MAX` check would let
// this through and overflow the int realloc size).
memset(&r, 0, sizeof(r));
r.soc = INVALID_SOCKET;
r.totalsize = -1;
r.size = (LLint) INT32_MAX - 8192;
http_xfread1(&r, 8192);
printf("boundary: msg=%s\n", r.msg);
// A legitimate small size must NOT be refused by the guard (the read then
// fails on the invalid socket, but the size-too-large msg must not be set).
memset(&r, 0, sizeof(r));
r.soc = INVALID_SOCKET;
r.totalsize = 1000;
http_xfread1(&r, 8192);
printf("accept: msg=%s\n", r.msg);
if (r.adr != NULL)
freet(r.adr);
return 0;
}
/* Parse a Content-Range header and print the sanitized triple. A hostile value
(negative or INT64 extreme) must clamp to 0 without signed-overflow UB. */
static int st_crange(httrackp *opt, int argc, char **argv) {
int i;
(void) opt;
if (argc < 1) {
fprintf(stderr, "crange: needs at least one raw Content-Range line\n");
return 1;
}
for (i = 0; i < argc; i++) {
htsblk r;
char BIGSTK line[HTS_URLMAXSIZE * 2];
memset(&r, 0, sizeof(r));
strcpybuff(line, argv[i]);
treathead(NULL, "www.example.com", "/", &r, line);
printf("crange_start=" LLintP " crange_end=" LLintP " crange=" LLintP "\n",
(LLint) r.crange_start, (LLint) r.crange_end, (LLint) r.crange);
}
return 0;
}
/* Decode a body argument ("hex:FFD8.." or literal text) into buf. */
static size_t st_decode_body(const char *arg, char *buf, size_t size) {
size_t n = 0;
@@ -2496,6 +2572,10 @@ static const struct selftest_entry {
{"headerlong", "[header-name:]",
"over-long header value must not overflow the parse scratch",
st_headerlong},
{"crange", "<raw-content-range-line> ...",
"Content-Range parse integer safety", st_crange},
{"xfread-limit", "", "in-memory receive buffer size bound",
st_xfread_limit},
{"savename", "<fil> <content-type> [key=value ...]",
"local save-name for a URL", st_savename},
{"sniff", "<content-type> <hex:..|text>", "MIME magic consistency",

34
tests/01_engine-crange.test Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
#
set -euo pipefail
# Content-Range parse (treathead via -#test=crange). Hostile values must clamp
# to 0, and the crange +/- 1 arithmetic must not sign-overflow (UBSan aborts on
# the pre-fix binary at the INT64_MIN cases).
cr() {
local want="$1"
shift
out="$(httrack -O /dev/null -#test=crange "$@")"
test "$out" == "$want" || {
echo "FAIL: $* -> '$out' (want '$want')"
exit 1
}
}
# Normal range and the '*/N' fallback both parse through unchanged.
cr 'crange_start=0 crange_end=70870 crange=70871' 'Content-Range: bytes 0-70870/70871'
cr 'crange_start=0 crange_end=70870 crange=70871' 'Content-Range: bytes */70871'
# INT64_MIN total in the fallback would make crange-1 overflow: clamp to 0.
cr 'crange_start=0 crange_end=0 crange=0' 'Content-Range: bytes */-9223372036854775808'
# Any negative field in the 3-field form clamps the whole triple to 0 (each
# field independently: a leading negative start/end alone still zeroes all).
cr 'crange_start=0 crange_end=0 crange=0' 'Content-Range: bytes -1--1/-1'
cr 'crange_start=0 crange_end=0 crange=0' 'Content-Range: bytes -5-10/20'
# INT64_MAX is non-negative: it passes through, the parse must not mangle it.
cr 'crange_start=0 crange_end=9223372036854775807 crange=9223372036854775807' \
'Content-Range: bytes 0-9223372036854775807/9223372036854775807'

29
tests/01_engine-xfread.test Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
#
set -euo pipefail
# http_xfread1 must refuse an in-memory receive buffer that would exceed a
# 32-bit index (hostile Content-Length, or an endless stream) rather than
# allocate it, while still accepting a normal small size. -#test=xfread-limit
# drives both refusal paths and the accept path.
out="$(httrack -O /dev/null -#test=xfread-limit)"
for case in bylen bygrow; do
echo "$out" | grep -q "${case}: refused=1 adr=null msg=In-memory content too large" || {
echo "FAIL ${case}: $out"
exit 1
}
done
# Exactly INT32_MAX must be refused too (the reallocs add 1).
echo "$out" | grep -q 'boundary: msg=In-memory content too large' || {
echo "FAIL boundary: $out"
exit 1
}
# The guard must NOT fire for a legitimate small size.
if echo "$out" | grep -q 'accept: msg=In-memory content too large'; then
echo "FAIL accept (guard fired on a legit size): $out"
exit 1
fi

View File

@@ -66,9 +66,12 @@ test -n "$port" || {
}
out="$tmpdir/crawl"
# macOS has only 127.0.0.1, so the dead 127.0.0.x connects block to the timeout
# instead of refusing instantly (as on Linux); a small --timeout + --retries=0
# bound the stall without changing what the fallback exercises.
HTTRACK_DEBUG_RESOLVE="deadhost:127.0.0.2,127.0.0.1" \
httrack "http://deadhost:$port/simple/basic.html" -O "$out" \
-c1 --robots=0 --timeout=30 --quiet -Z >"$tmpdir/log" 2>&1
-c1 --robots=0 --timeout=5 --retries=0 --quiet -Z >"$tmpdir/log" 2>&1
log="$out/hts-log.txt"
@@ -92,12 +95,12 @@ test -f "$out/deadhost_$port/simple/basic.html" || {
exit 1
}
# every address refused: the slot exhausts the list, then errors out (the
# harness timeout would catch a hang/loop; refused connects are instant)
# every address dead: the slot exhausts the list, then errors out (the harness
# timeout would catch a hang/loop)
out2="$tmpdir/crawl2"
HTTRACK_DEBUG_RESOLVE="alldead:127.0.0.2,127.0.0.3" \
httrack "http://alldead:$port/simple/basic.html" -O "$out2" \
-c1 --robots=0 --timeout=30 --quiet -Z >"$tmpdir/log2" 2>&1
-c1 --robots=0 --timeout=5 --retries=0 --quiet -Z >"$tmpdir/log2" 2>&1
log2="$out2/hts-log.txt"
grep -q "trying next address" "$log2" || {

View File

@@ -17,7 +17,10 @@ printf 'GIF89a' >"$tmproot/javaclass/hello.gif"
printf '\xCA\xFE\xBA\xBE\x00\x00\x00\x32\x00\x02\x01\x00\x09hello.gif\x00\x00\x00\x00' \
>"$tmproot/javaclass/Foo.class"
# --log-found first: a plugin-load failure (wrong soname, shadowing system
# libhtsjava) trips here on the banner suffix, not as an opaque "not found".
bash "$top_srcdir/tests/local-crawl.sh" --root "$tmproot" --errors 0 \
--log-found '\+libhtsjava' \
--found 'javaclass/Foo.class' \
--found 'javaclass/hello.gif' \
httrack 'BASEURL/javaclass/index.html'

View File

@@ -0,0 +1,16 @@
#!/bin/bash
#
# -M caps received volume, not saved 200-only bytes (#520): links to large 404
# bodies pump received >> saved; the cap must trip though little lands on disk.
set -euo pipefail
: "${top_srcdir:=..}"
# The mirror bound is the load-bearing half: it fires while <100 KB is saved,
# so the cap can only have tripped on received volume, not saved bytes (which
# would exceed the cap and fetch all 16 links).
bash "$top_srcdir/tests/local-crawl.sh" \
--log-found 'More than 500000 bytes have been transferred.. giving up' \
--max-mirror-bytes 100000 \
httrack 'BASEURL/maxrecv/index.html' -M500000 -c4

View File

@@ -0,0 +1,118 @@
#!/bin/bash
# C7: a server 304 answering a resume's Range request is out of protocol; httrack
# must drop the partial and refetch, not finalize it as complete at the partial
# byte count. Pass 1 leaves a partial + temp-ref; pass 2 resumes, gets a bogus
# 304, and must recover the whole file.
set -u
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
server="${testdir}/local-server.py"
command -v python3 >/dev/null || ! echo "python3 not found; skipping" || exit 77
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_c7.XXXXXX") || exit 1
serverpid=
crawlpid=
cleanup() {
test -n "$crawlpid" && kill -9 "$crawlpid" 2>/dev/null
if test -n "$serverpid"; then
kill "$serverpid" 2>/dev/null
wait "$serverpid" 2>/dev/null
fi
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
serverlog="${tmpdir}/server.log"
counter="${tmpdir}/reqcount"
mark="${tmpdir}/got304"
RESUME304_COUNTER="$counter" RESUME304_MARK="$mark" python3 "$server" --root "${testdir}/server-root" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$serverlog" 2>/dev/null)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
break
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$serverlog")"
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover server port"
exit 1
}
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
out="${tmpdir}/crawl"
mkdir "$out"
common=(-O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 --retries=1 -c1)
refdir="${out}/hts-cache/ref"
# --- pass 1: crawl, interrupt once the blob download is underway -------------
printf '[pass 1: interrupt mid-download] ..\t'
httrack "${common[@]}" "${base}/resume304/index.html" >"${tmpdir}/log1" 2>&1 &
crawlpid=$!
for _ in $(seq 1 300); do
test -s "$counter" && break
kill -0 "$crawlpid" 2>/dev/null || break
sleep 0.1
done
sleep 0.5
kill -TERM "$crawlpid" 2>/dev/null
wait "$crawlpid" 2>/dev/null
crawlpid=
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" || {
echo "FAIL: no temp-ref survived pass 1; cannot drive C7"
exit 1
}
echo "OK (temp-ref present)"
before=$(wc -c <"$counter" 2>/dev/null || echo 0)
# --- pass 2: --continue -> resume Range -> bogus 304 -> drop + refetch --------
printf '[pass 2: resume gets 304, must refetch] ..\t'
httrack "${common[@]}" --continue "${base}/resume304/index.html" >"${tmpdir}/log2" 2>&1
echo "OK (terminated)"
blob=$(find "$out" -name blob.bin 2>/dev/null | head -1)
full=8200 # len(RESUME304_BODY) = len("C7DATA--") + 8192
printf '[file recovered whole] ..\t'
test -s "$blob" || {
echo "FAIL: blob.bin missing after the 304 resume (partial dropped, not refetched)"
exit 1
}
got=$(wc -c <"$blob")
test "$got" -eq "$full" || {
echo "FAIL: blob.bin is ${got} bytes, expected ${full} (partial finalized as complete)"
exit 1
}
echo "OK (${got} bytes)"
# Pass 2 must issue at least two requests: the resume (Range -> 304) and the
# range-less refetch (-> 200). Exactly one means the partial was trusted.
after=$(wc -c <"$counter" 2>/dev/null || echo 0)
hits=$((after - before))
printf '[resume then refetch] ..\t'
test "$hits" -ge 2 || {
echo "FAIL: only ${hits} pass-2 request(s); the 304 was trusted, no refetch"
exit 1
}
echo "OK (${hits} requests)"
# The recovery must go through the resume path: a Range request that drew the
# bogus 304 (a fresh re-crawl sends no Range, so this marker stays empty).
printf '[resume Range drew a 304] ..\t'
test -s "$mark" || {
echo "FAIL: no Range request got a 304; the resume path was not exercised"
exit 1
}
echo "OK"

View File

@@ -0,0 +1,96 @@
#!/bin/bash
# C2: a resume whose 206 carries a Content-Range end of INT64_MAX must not
# sign-overflow the crange+1 range check (UBSan aborts on the pre-fix binary).
# Pass 1 leaves a partial + temp-ref; pass 2 resumes, gets the hostile 206, and
# must reject the range and refetch the whole file rather than overflow. Teeth
# are UBSan-only: a normal build wraps to the same reject+restart.
set -u
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
server="${testdir}/local-server.py"
command -v python3 >/dev/null || ! echo "python3 not found; skipping" || exit 77
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_crange.XXXXXX") || exit 1
serverpid=
crawlpid=
cleanup() {
test -n "$crawlpid" && kill -9 "$crawlpid" 2>/dev/null
if test -n "$serverpid"; then
kill "$serverpid" 2>/dev/null
wait "$serverpid" 2>/dev/null
fi
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
serverlog="${tmpdir}/server.log"
python3 "$server" --root "${testdir}/server-root" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$serverlog" 2>/dev/null)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
break
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$serverlog")"
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover server port"
exit 1
}
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
out="${tmpdir}/crawl"
mkdir "$out"
common=(-O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 --retries=1 -c1)
refdir="${out}/hts-cache/ref"
# --- pass 1: crawl, interrupt once the blob download is underway -------------
printf '[pass 1: interrupt mid-download] ..\t'
httrack "${common[@]}" "${base}/crange206/index.html" >"${tmpdir}/log1" 2>&1 &
crawlpid=$!
for _ in $(seq 1 300); do
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" && break
kill -0 "$crawlpid" 2>/dev/null || break
sleep 0.1
done
sleep 0.3
kill -TERM "$crawlpid" 2>/dev/null
wait "$crawlpid" 2>/dev/null
crawlpid=
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" || {
echo "FAIL: no temp-ref survived pass 1; cannot drive the resume"
exit 1
}
echo "OK (temp-ref present)"
# --- pass 2: --continue -> resume -> hostile INT64_MAX Content-Range ----------
printf '[pass 2: hostile 206, no overflow, refetch] ..\t'
httrack "${common[@]}" --continue "${base}/crange206/index.html" >"${tmpdir}/log2" 2>&1
echo "OK (terminated)"
blob=$(find "$out" -name blob.bin 2>/dev/null | head -1)
full=6008 # len(CRANGE206_BODY) = len("CR206DAT") + 6000
printf '[file recovered whole] ..\t'
test -s "$blob" || {
echo "FAIL: blob.bin missing after the hostile 206"
exit 1
}
got=$(wc -c <"$blob")
test "$got" -eq "$full" || {
echo "FAIL: blob.bin is ${got} bytes, expected ${full}"
exit 1
}
echo "OK (${got} bytes)"

View File

@@ -0,0 +1,96 @@
#!/bin/bash
# C2 (memory branch): a resume whose 206 lies text/html with a matching
# INT64_MAX Content-Length must not overflow the resume buffer-size add
# (UBSan aborts on the pre-fix binary at alloc_mem += totalsize). Pass 1 leaves
# a partial + temp-ref; pass 2 resumes and must reject and refetch the whole
# file. Teeth are UBSan-only: a normal build wraps to the same reject+restart.
set -u
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
server="${testdir}/local-server.py"
command -v python3 >/dev/null || ! echo "python3 not found; skipping" || exit 77
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_crangemem.XXXXXX") || exit 1
serverpid=
crawlpid=
cleanup() {
test -n "$crawlpid" && kill -9 "$crawlpid" 2>/dev/null
if test -n "$serverpid"; then
kill "$serverpid" 2>/dev/null
wait "$serverpid" 2>/dev/null
fi
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
serverlog="${tmpdir}/server.log"
python3 "$server" --root "${testdir}/server-root" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$serverlog" 2>/dev/null)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
break
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$serverlog")"
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover server port"
exit 1
}
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
out="${tmpdir}/crawl"
mkdir "$out"
common=(-O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 --retries=1 -c1)
refdir="${out}/hts-cache/ref"
# --- pass 1: crawl, interrupt once the blob download is underway -------------
printf '[pass 1: interrupt mid-download] ..\t'
httrack "${common[@]}" "${base}/crange206mem/index.html" >"${tmpdir}/log1" 2>&1 &
crawlpid=$!
for _ in $(seq 1 300); do
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" && break
kill -0 "$crawlpid" 2>/dev/null || break
sleep 0.1
done
sleep 0.3
kill -TERM "$crawlpid" 2>/dev/null
wait "$crawlpid" 2>/dev/null
crawlpid=
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" || {
echo "FAIL: no temp-ref survived pass 1; cannot drive the resume"
exit 1
}
echo "OK (temp-ref present)"
# --- pass 2: --continue -> resume -> hostile INT64_MAX Content-Range ----------
printf '[pass 2: hostile 206, no overflow, refetch] ..\t'
httrack "${common[@]}" --continue "${base}/crange206mem/index.html" >"${tmpdir}/log2" 2>&1
echo "OK (terminated)"
blob=$(find "$out" -name blob.bin 2>/dev/null | head -1)
full=6008 # len(CRANGE206_BODY) = len("CR206DAT") + 6000
printf '[file recovered whole] ..\t'
test -s "$blob" || {
echo "FAIL: blob.bin missing after the hostile 206"
exit 1
}
got=$(wc -c <"$blob")
test "$got" -eq "$full" || {
echo "FAIL: blob.bin is ${got} bytes, expected ${full}"
exit 1
}
echo "OK (${got} bytes)"

View File

@@ -30,6 +30,7 @@ TESTS = \
01_engine-cmdline.test \
01_engine-cookies.test \
01_engine-copyopt.test \
01_engine-crange.test \
01_engine-dns.test \
01_engine-doitlog.test \
01_engine-entities.test \
@@ -65,6 +66,7 @@ TESTS = \
01_engine-urlhack.test \
01_engine-unescape-bounds.test \
01_engine-useragent.test \
01_engine-xfread.test \
01_zlib-acceptencoding.test \
01_zlib-cache.test \
01_zlib-cache-corrupt.test \
@@ -113,6 +115,10 @@ TESTS = \
41_local-utf8-link.test \
42_local-maxsize-slow.test \
43_local-update-truncate.test \
44_local-update-errormask.test
44_local-update-errormask.test \
45_local-maxsize-recv.test \
46_local-update-304-resume.test \
47_local-crange-overflow.test \
48_local-crange-memresume.test
CLEANFILES = check-network_sh.cache

View File

@@ -105,7 +105,7 @@ function start-crawl {
log="${tmp}/log"
debug starting httrack -O "${tmp}" "${moreargs[@]}" "${@:pos}"
info "running httrack ${*:pos}"
httrack -O "${tmp}" --user-agent="httrack $ver ut ($(uname -omrs))" "${moreargs[@]}" "${@:pos}" >"${log}" 2>&1 &
httrack -O "${tmp}" --user-agent="httrack $ver ut ($(uname -mrs))" "${moreargs[@]}" "${@:pos}" >"${log}" 2>&1 &
crawlpid="$!"
debug "started cralwer on pid $crawlpid"
wait "$crawlpid"

View File

@@ -213,7 +213,7 @@ mkdir "$out" || die "could not create $out"
declare -a moreargs=(--quiet --max-time=120 --timeout=30 --disable-security-limits --robots=0)
log="${tmpdir}/log"
info "running httrack ${hts[*]}"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -omrs))" "${moreargs[@]}" "${hts[@]}" >"$log" 2>&1 &
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" "${moreargs[@]}" "${hts[@]}" >"$log" 2>&1 &
crawlpid=$!
wait "$crawlpid"
crawlres=$?
@@ -230,7 +230,7 @@ grep -iE "^[0-9:]*[[:space:]]Error:" "${out}/hts-log.txt" >&2
# --- optional second pass: re-mirror into the same dir (cache/update path) ----
if test -n "$rerun"; then
info "re-running httrack (update pass)"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -omrs))" \
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" >"${log}.2" 2>&1 &
crawlpid=$!
wait "$crawlpid"
@@ -258,7 +258,7 @@ fi
if test -n "$rerun_args"; then
read -ra extra <<<"$rerun_args"
info "re-running httrack with ${rerun_args}"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -omrs))" \
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" "${extra[@]}" >"${log}.2" 2>&1 &
crawlpid=$!
wait "$crawlpid"
@@ -281,7 +281,7 @@ if test -n "$rerun_dead"; then
wait "$serverpid" 2>/dev/null
serverpid=
info "re-running httrack against the stopped server"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -omrs))" \
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" >"${log}.dead" 2>&1 &
crawlpid=$!
wait "$crawlpid" || true

View File

@@ -790,6 +790,56 @@ class Handler(SimpleHTTPRequestHandler):
self.send_header("Content-Length", "0")
self.end_headers()
# C7: stall the first GET (partial + temp-ref), then answer the resume's
# Range with a bogus 304; httrack must drop the partial and refetch.
RESUME304_BODY = b"C7DATA--" + bytes((i * 7 + 3) % 256 for i in range(8192))
_resume304_started = False
def route_resume304_index(self):
self.send_html('\t<a href="blob.bin">blob</a>')
def route_resume304(self):
counter = os.environ.get("RESUME304_COUNTER")
if counter:
with open(counter, "a") as fp:
fp.write("x")
rng = self.headers.get("Range")
if not Handler._resume304_started:
Handler._resume304_started = True
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.RESUME304_BODY)))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Last-Modified", BIG_LASTMOD)
self.send_header("ETag", '"c7"')
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.RESUME304_BODY[:4096])
self.wfile.flush()
try:
while True:
time.sleep(3600)
except OSError:
pass
return
if rng is not None: # resume request: bogus out-of-protocol 304
mark = os.environ.get("RESUME304_MARK")
if mark:
with open(mark, "a") as fp:
fp.write("z")
self.send_response(304)
self.send_header("ETag", '"c7"')
self.send_header("Content-Length", "0")
self.end_headers()
return
# Range-less refetch after the partial is dropped: whole file.
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.RESUME304_BODY)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.RESUME304_BODY)
# 206 resume must honor the server's Content-Range, not the offset we asked
# for (#198): a server resuming a few bytes *before* the request must not
# leave httrack duplicating the overlap onto the partial. flaky.bin
@@ -855,6 +905,94 @@ class Handler(SimpleHTTPRequestHandler):
if self.command != "HEAD":
self.wfile.write(part)
# C2: a resume answered with a 206 whose Content-Range end is INT64_MAX would
# sign-overflow the crange+1 range check (UBSan abort). Stall first (partial +
# ref), then answer the resume Range with that hostile 206; httrack must reject
# the range and refetch, never overflow.
CRANGE206_BODY = b"CR206DAT" + bytes((i * 5 + 1) % 256 for i in range(6000))
_crange206_started = False
def route_crange206_index(self):
self.send_html('\t<a href="blob.bin">blob</a>')
def route_crange206(self):
rng = self.headers.get("Range")
if not Handler._crange206_started:
Handler._crange206_started = True
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.CRANGE206_BODY)))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.CRANGE206_BODY[:3000])
self.wfile.flush()
try:
while True:
time.sleep(3600)
except OSError:
pass
return
if rng is not None: # resume: hostile 206, Content-Range end = INT64_MAX
self.send_response(206, "Partial Content")
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.CRANGE206_BODY)))
self.send_header("Content-Range", "bytes 0-9223372036854775807/1")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.CRANGE206_BODY)
return
# range-less refetch after the bad range is rejected: whole file.
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.CRANGE206_BODY)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.CRANGE206_BODY)
# C2 memory branch: a resume answered with a 206 that lies text/html (so the
# resume buffers in memory) plus a matching INT64_MAX Content-Length would
# overflow the buffer-size add. Stall first, then send that hostile 206.
_crange206mem_started = False
def route_crange206mem_index(self):
self.send_html('\t<a href="blob.bin">blob</a>')
def route_crange206mem(self):
rng = self.headers.get("Range")
if not Handler._crange206mem_started:
Handler._crange206mem_started = True
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.CRANGE206_BODY)))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.CRANGE206_BODY[:3000])
self.wfile.flush()
try:
while True:
time.sleep(3600)
except OSError:
pass
return
if rng is not None: # resume: text/html + matching INT64_MAX Content-Length
self.send_response(206, "Partial Content")
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", "9223372036854775807")
self.send_header(
"Content-Range", "bytes 0-9223372036854775806/9223372036854775807"
)
self.end_headers()
return # the overflow is computed before any body read
# range-less refetch after the resume is rejected: whole file.
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(self.CRANGE206_BODY)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.CRANGE206_BODY)
# error pages / 0-byte files (#17): -o0 ("no error pages") must keep 4xx/5xx
# bodies off disk; a genuine 0-byte 200 is a valid file and stays.
def route_errpage_index(self):
@@ -1104,6 +1242,22 @@ class Handler(SimpleHTTPRequestHandler):
except OSError:
pass
# -M received-volume cap (#520): links to large 404 bodies. httrack receives
# each (HTS_TOTAL_RECV climbs) but saves none, so saved stays far below -M.
def route_maxrecv_index(self):
self.send_html(
"".join('\t<a href="r%d.bin">r%d</a>\n' % (i, i) for i in range(16))
)
def route_maxrecv_404(self):
body = b"x" * self.BIGFILE_BYTES
self.send_response(404, "Not Found")
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
ROUTES = {
"/cookies/entrance.php": route_entrance,
"/cookies/second.php": route_second,
@@ -1133,9 +1287,15 @@ class Handler(SimpleHTTPRequestHandler):
"/intl/" + INTL_NAME: route_intl_page,
"/resume/index.html": route_resume_index,
"/resume/blob.txt": route_resume,
"/resume304/index.html": route_resume304_index,
"/resume304/blob.bin": route_resume304,
"/overlap/index.html": route_overlap_index,
"/overlap/flaky.bin": route_overlap,
"/overlap/full.bin": route_overlap_full,
"/crange206/index.html": route_crange206_index,
"/crange206/blob.bin": route_crange206,
"/crange206mem/index.html": route_crange206mem_index,
"/crange206mem/blob.bin": route_crange206mem,
"/size/index.html": route_size_index,
"/size/oversize.bin": route_size_oversize,
"/errpage/index.html": route_errpage_index,
@@ -1206,6 +1366,23 @@ class Handler(SimpleHTTPRequestHandler):
"/mini304/page.html": route_mini304_page,
"/errmask/index.html": route_errmask_index,
"/errmask/keep.dat": route_errmask_keep,
"/maxrecv/index.html": route_maxrecv_index,
"/maxrecv/r0.bin": route_maxrecv_404,
"/maxrecv/r1.bin": route_maxrecv_404,
"/maxrecv/r2.bin": route_maxrecv_404,
"/maxrecv/r3.bin": route_maxrecv_404,
"/maxrecv/r4.bin": route_maxrecv_404,
"/maxrecv/r5.bin": route_maxrecv_404,
"/maxrecv/r6.bin": route_maxrecv_404,
"/maxrecv/r7.bin": route_maxrecv_404,
"/maxrecv/r8.bin": route_maxrecv_404,
"/maxrecv/r9.bin": route_maxrecv_404,
"/maxrecv/r10.bin": route_maxrecv_404,
"/maxrecv/r11.bin": route_maxrecv_404,
"/maxrecv/r12.bin": route_maxrecv_404,
"/maxrecv/r13.bin": route_maxrecv_404,
"/maxrecv/r14.bin": route_maxrecv_404,
"/maxrecv/r15.bin": route_maxrecv_404,
}
# --- /big/ seeded pseudo-site ------------------------------------------