Skip to content

Harden dataexchange against resource-exhaustion DoS#25

Merged
TeoSlayer merged 1 commit into
mainfrom
harden-dataexchange-dos
Jun 22, 2026
Merged

Harden dataexchange against resource-exhaustion DoS#25
TeoSlayer merged 1 commit into
mainfrom
harden-dataexchange-dos

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

Hardens the dataexchange service against resource-exhaustion / DoS and fixes two correctness bugs. Each change bounds the memory, disk, or connection resources a single peer can commit.

Changes

  1. 1 GiB frame cap + up-front allocation → bounded. ReadFrame no longer pre-allocates the attacker-declared frame length; it grows the payload buffer incrementally as bytes actually arrive (readBounded, 64 KiB initial reservation, geometric growth). A peer that announces a giant in-cap frame and never sends the bytes now ties up only the small initial buffer, not the declared size. The default per-frame cap (DefaultMaxFrameSize) drops from 1 GiB to 64 MiB — large transfers belong on the chunked TypeFileStream path, which never buffers a whole file. Still raisable via PILOT_DATAEXCHANGE_MAX_FRAME.

  2. Per-connection read/idle deadline. handleConn resets a read deadline before every frame (ServiceConfig.IdleTimeout, default 2 min) via an optional SetReadDeadline type-assertion — enforced on the production *driver.Conn, a no-op on test pipes. Slowloris peers are dropped instead of pinning a goroutine forever.

  3. Inbox total-byte cap. InboxMaxBytes now defaults to 256 MiB (was off) and is enforced on every receipt, alongside the existing file-count cap.

  4. Disk quota for received files + stream partials. ReceivedMaxBytes (default 2 GiB) covers completed files and retained .partial fragments, enforced before every write on both the legacy TypeFile path and the chunked TypeFileStream path (quota gate on INIT + per-chunk debit, so a peer that lies about its declared size still can't overrun).

  5. Lossless binary inbox storage. Binary / non-UTF-8 payloads are stored as base64 (data_b64) with a data_encoding marker instead of being mangled to U+FFFD inside a JSON string. UTF-8 text is unchanged; IncludeBase64 still forces base64 for everything.

  6. Filename length validated before the uint16 cast. WriteFrame rejects names over maxFilenameLen (255) up front, so an over-long name can't wrap/truncate onto the wire.

A negative value for InboxMaxBytes / ReceivedMaxBytes / IdleTimeout disables that limit (escape hatch); zero selects the default.

Tests

New zz_harden_test.go covers: oversized-declared frame rejected without a large allocation (asserts the max single read-buffer ask stays bounded), over-cap frame still rejected at the header, over-long filename rejected on write, binary payload round-trips losslessly, inbox byte cap enforced on receipt, received-files quota enforced (legacy + streamed INIT), read deadline fires on an idle peer, and disabled-deadline opt-out. The default-cap pin test was updated to 64 MiB.

Validation

  • GOWORK=off go build ./... and go build -tags=no_dataexchange ./... — clean
  • GOWORK=off go vet ./... — clean
  • GOWORK=off go test -race -parallel 4 ./... — green
  • gofmt -l . — clean
  • web4 consumer (cmd/daemon, cmd/pilotctl) builds clean against this module via local replace, in both default and no_dataexchange configs

Notes

  • ServiceConfig gains ReceivedMaxBytes and IdleTimeout fields (and the disabled-build stub is re-synced); existing web4 call sites compile unchanged.
  • A pre-existing issue unrelated to this PR: several zz_*_test.go files lack the !no_dataexchange build tag and so fail go vet -tags=no_dataexchange on main already. Left untouched; the CI gate runs the default-tag test build, which is green.

Bound the memory, disk, and connection resources a single peer can
commit, and fix lossy binary storage and an unvalidated filename cast.

- ReadFrame: stop pre-allocating the attacker-declared frame size; grow
  the payload buffer incrementally as bytes arrive. Lower the default
  per-frame cap from 1 GiB to 64 MiB (env-overridable).
- service: add a per-connection idle/read deadline (default 2m) reset
  before every frame to drop slowloris peers.
- service: default and enforce a total-byte inbox cap (256 MiB) on every
  receipt, alongside the existing file-count cap.
- service + filestream: add a received-files disk quota (default 2 GiB)
  covering completed files and retained .partial fragments, enforced on
  write for both the legacy TypeFile and chunked TypeFileStream paths.
- service: store binary / non-UTF-8 inbox payloads as base64 with a
  data_encoding marker instead of corrupting them into a JSON string.
- WriteFrame: reject over-long filenames before the uint16 cast so a
  name cannot wrap or truncate onto the wire.

A negative limit value disables that limit; zero selects the default.
Adds tests for each: oversized-declared frame rejected without a large
allocation, read deadline fires, inbox byte cap enforced, received-files
quota enforced, over-long filename rejected, binary round-trips losslessly.
@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@TeoSlayer TeoSlayer merged commit 1cd6f58 into main Jun 22, 2026
5 checks passed
@matthew-pilot matthew-pilot deleted the harden-dataexchange-dos branch June 22, 2026 15:48
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