Skip to content

Fix resumable-transport DDoS: terminate degenerate pullBundleChunk requests - #27

Open
hahn-kev-bot wants to merge 1 commit into
masterfrom
claude/youthful-bardeen-a5b13f
Open

hahn-kev-bot wants to merge 1 commit into
masterfrom
claude/youthful-bardeen-a5b13f

Conversation

@hahn-kev-bot

@hahn-kev-bot hahn-kev-bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

better failure state handling, based on a recent incident which took down the server


🤖 AI summary

Incident: A single "Low Bandwidth" (HgResume v03 / resumable) client DDoS'd prod by looping pullBundleChunk against an empty repo (sena-3) with stale cached base hashes. The server's isValidBase spun on the empty repo and OOM-killed the container; the client retried every FAIL(400) with no backoff. This closes the server side of that incident so every degenerate or malicious request terminates with a correct, non-retryable-forever response.

All changes are in csharp/src/HgResume.Api (the C# rewrite).

A — empty/missing baseHashes (the live bug). baseHashes is now optional in RestDispatcher (a cache-cleared client re-sends pullBundleChunk with none; requiring it produced a retried FAIL(400)), and HgRunner.MakeBundle treats an empty list as --all. Result: empty repo → NOCHANGE, non-empty repo → full clone. Both terminate.

B — isValidBase → direct per-hash lookup. Replaced the whole-history paging scan with a per-hash hg log -r <hash> existence check (O(k)), hex-validated (^[0-9a-fA-F]{1,40}$) and passed positionally (no revset/shell injection). Independently fixes the empty-repo spin.

C — hg reliability. ProcessRunner now returns exit code and stderr; getRevisions/getBranchTips/isValidBase key on the exit code and fail fast with the stderr on a real hg error, instead of inferring failure from empty stdout (the old uninformative command 'hg log' failed!). The empty-repo -1→0 sentinel is preserved.

D — bounded getRevisions. Each hg log is bounded to O(offset+quantity) via -l, preserving newest-first ordering and offset semantics.

E — concurrent-generation guard + crash recovery. Before MakeBundle, an in-flight generation is detected via the in-process Running registry (and lock-file age) and polled rather than duplicated; a stale .async_run left by a crashed/restarted process is reaped and respawned; respawns are bounded by a genAttempts counter → FAIL.

F — cache GC. New CacheGarbageCollector BackgroundService reaps .bundle/.metadata/.async_run/.incoming older than ~24h (prod leaked 1,258 .async_run locks).

G — chunkSize clamp to a config cap (HGRESUME_CHUNK_SIZE_MAX, default 20 MB).

All new behaviour is env-overridable via ApiConfig with safe defaults.

Test plan

  • HgResume.Api.Tests (unit): 17/17 — includes new CacheGarbageCollectorTests (reaps stale artifacts; leaves fresh/unrelated files).
  • HgResume.IntegrationTests (Testcontainers, real image):
    • PullFacts 19/19 — includes new regressions: empty-repo first-sync with no baseHashes → NOCHANGE then push succeeds; non-empty repo with no baseHashes → servable full-clone bundle; plus the existing empty-repo-loop and missing-base-across-pages regressions.
    • PushFacts + MiscFacts + ContractFacts 31/31.
    • SendReceiveTests + VerifyHgWorking (end-to-end Chorus client) 4/4.

This change is Reviewable

…quests

A single resumable client looped pullBundleChunk against an empty repo with
stale cached base hashes, spinning isValidBase and OOM-killing the container.
Make every degenerate request terminate with a correct, non-retryable response:

A. baseHashes is now optional in RestDispatcher; MakeBundle treats an empty
   list as --all. A cache-cleared client (no baseHashes) gets NOCHANGE on an
   empty repo / a full clone on a non-empty one instead of a retried FAIL(400).
B. isValidBase does a direct per-hash `hg log -r <hash>` existence check (O(k),
   hex-validated) instead of paging the whole history.
C. ProcessRunner surfaces exit code + stderr; getRevisions/getBranchTips/
   isValidBase fail fast on a real hg error instead of inferring from empty stdout.
D. getRevisions bounds each `hg log` to O(offset+quantity) via -l, preserving
   newest-first ordering and offset semantics.
E. Concurrent-generation guard (in-process registry), stale-lock crash recovery,
   and bounded respawn -> FAIL before MakeBundle.
F. CacheGarbageCollector BackgroundService reaps abandoned .bundle/.metadata/
   .async_run/.incoming artifacts (~24h TTL); prod leaked 1,258 .async_run locks.
G. Clamp client-supplied chunkSize to a config cap.

Tests: new PullFacts regressions (empty-repo first-sync with no baseHashes ->
NOCHANGE then push; non-empty repo with no baseHashes -> servable full clone)
and CacheGarbageCollector unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hahn-kev
hahn-kev requested a review from rmunn October 2, 2026 07:20

@rmunn rmunn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One style nit that doesn't need to be addressed but I wanted to mention, and a few minor changes that should be addressed, like changing the lower bound on the regex repeat count. Then after addressing those minor changes, no need for a re-review; it LGTM and can be merged once those changes are addressed. (Which is why I've approved it now rather than using "Request changes", in case I'm unavailable after the changes are made).

catch (OperationCanceledException)
{
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style comment only. This function is an example of why I dislike the "opening and closing braces on their own line" style that seems to have become standard in C#. I would much rather see this code written as:

try { Collect(); }
catch (Exception e) { _logger.LogWarning(e, "Cache GC pass failed"); }

try { await Task.Delay(_config.CacheGcInterval, stoppingToken); }
catch (OperationCanceledException) { break ; }

5 lines instead of 17, and taking up much less vertical space on-screen. With functions taking up less vertical space, it's easier to read them in the context of surrounding code, rather than scrolling up and down to see surrounding context.

Very much a style nitpick. But this is one of the things that bugs me every time I see it, and this while loop is one of the best examples I've ever seen where readability would have been much improved by having fewer lines of code.

private static readonly Regex NotAMercurialBundle = new("abort:.*not a Mercurial bundle", RegexOptions.Compiled);

// A hg short (12-char) or full (40-char) node id. Anything else cannot be a base revision.
private static readonly Regex HexHash = new("^[0-9a-fA-F]{1,40}$", RegexOptions.Compiled);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says 12-char or 40-char hashes, but this regex would match numerical revision numbers like 3 (or even 0) as well, because the {1,40} repeat modifier is lower-bounded at 1. One of the other comments mentions trying to reject revision-number values like 0; if that's the case, then the {1,40} here should probably be {12,40}. Or, if it's possible for Mercurial to produce smaller short hex IDs, {8,40} at the minimum.

Or, if revnums like 0 should actually match here, then the comment should be adjusted accordingly.

// hg log exited 0 with no output but this is not the empty-repo sentinel — surface it rather
// than silently returning empty.
throw new HgException(
$"command 'hg log' returned no revisions (hg tip exit {tipExit}): {Truncate(tipErr)}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change hg tip exit to hg tip exit code so it's more obvious in the log that it's a numeric exit code.

return false;
}
var (output, exitCode, stderr) = await ProcessRunner.RunAsync(RepoPath, "hg",
["log", "-r", hash, "--template", "{node|short}\n"], ct);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use {node} here instead of {node|short} then it will pass the very-careful caching check created in sillsdev/chorus#258 and then if the same revnum is ever checked twice, the second check will return a cached value instead of re-running hg log on the same revnum.

... No, wait. This is going through ProcessRunner, not HgRunner. The Chorus caching code never sees this run. Never mind, this isn't going to have any effect on the Chorus caching.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible we may want to implement something like the CHORUS_HG_EXE environment variable and use chg rather than hg here, paying far less of a process startup penalty for each hg log invocation after the first.

throw new HgException($"command 'hg log -r' failed (exit {exitCode}): {Truncate(stderr)}");
}

private static string Truncate(string s) => s.Length > 500 ? s.Substring(0, 500) : s;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect 500 bytes might truncate in the middle of a stacktrace. Shall we make this 4096 bytes instead?

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.

3 participants