Skip to content

Harden upload security, complete TUS protocol support, upgrade to Quarkus 3.38.0 - #2

Merged
jnbdz merged 17 commits into
masterfrom
harden-tus-security-and-upgrade-quarkus
Aug 4, 2026
Merged

jnbdz merged 17 commits into
masterfrom
harden-tus-security-and-upgrade-quarkus

Conversation

@jnbdz

@jnbdz jnbdz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Upgrades the build, closes every finding from a four-dimension review of the upgraded tree, and completes TUS protocol support. 13 commits, each independently reviewable. Tests go from 143 to 229.

1. Upgrade

Quarkus 3.36.2 → 3.38.0, Gradle wrapper 9.5.1 → 9.6.1 (with its pinned SHA-256 updated). JaCoCo, awaitility and microprofile-openapi-api were already current.

2. Authorization

Concatenation ownership bypass. When the ownership-checked merge refused, postCreate fell through to mergePartialUploadsUnfinished, which had no ownership check at all. An authenticated user could name another user's partial upload IDs, get a 201, and on the next HEAD have the victim's bytes merged into their own upload while the victim's partials were deleted. Both merge methods now take a requiredOwnerId, and every referenced partial is validated up front rather than via a blind fallback.

Missing per-upload authorization (IDOR). getUploaderId was populated on create but never consulted, so a known upload ID granted any authenticated user full read/write/delete. HEAD, PATCH, DELETE and the SSE streams now enforce ownership through a shared TusUploadAuthorizer, answering 404 rather than 403 so responses can't be used to probe for other users' IDs.

quarkus.tus.path could silently disable authentication. The endpoints are a constant @Path, but the auth and rate-limit filters gated on the configured value — so changing it left the endpoints live while the filters stopped matching them. Filters now match the real mount point, and a build step rejects any other value.

3. Concurrency

PATCH offset TOCTOU. The offset was validated before the lock was acquired, so two requests could both pass validation and then write in turn. With differently sized chunks the second overwrote part of the first while both returned 204 — silent corruption no checksum could catch, reachable by a client retrying an in-flight PATCH. Validation now happens under the lock, and the store rejects a write at a stale offset with 409 instead of trusting the caller.

discardUpload ignored the lock. A DELETE or cleanup job landing mid-write unlinked the file underneath the in-flight write, which then reported success for bytes no longer stored. It now acquires the lock and returns false if it can't; DELETE answers 423 while a write is in progress.

4. Abuse resistance

  • Rate limiting trusted X-Forwarded-For — a client varying it per request got a fresh burst every time, making the limiter a no-op and growing an unbounded bucket map. Clients are now keyed on principal, else the peer address; deployments behind a proxy set quarkus.http.proxy.proxy-address-forwarding.
  • Completion event replay — a PATCH at the final offset with an empty body re-fired TusUploadCompletedEvent every time, letting any client replay whatever observers do (move files, insert rows, call webhooks, bill). Completion is now latched and the latch is persisted.
  • Concatenation amplification — repeating a partial reference multiplied it on disk. Duplicates are rejected, and quarkus.tus.max-concat-parts caps the count.
  • SSE streams — no ownership check, no UUID validation, and registering a sink silently displaced and leaked the previous subscriber's connection.

5. Packaging — affects consumers of the published extension

  • Removed runtime/.../META-INF/beans.xml. It made Quarkus index the whole runtime jar, defeating every conditional AdditionalBeanBuildItem. Resources and providers are now registered explicitly via AdditionalIndexedClassesBuildItem, gated on the same config, so the conditionals are real.
  • TusMetricsService gated on Capability.METRICS — it references Micrometer types while Micrometer is compileOnly, so consumers without that extension failed on first upload.
  • Dropped the hard quarkus-smallrye-health-deployment dependency, which forced its own capability check to always be true.

6. Protocol conformance — now complete

X-HTTP-Method-Override (a core MUST, previously absent), Upload-Concat echoed verbatim as received, Tus-Resumable guaranteed on container-generated responses, PATCH rejected on unfinished final concatenations, creation-with-upload bodies bounded by Upload-Length, invalid concat references rejected instead of silently dropped, and malformed/unsupported checksums returning 400 instead of being accepted unverified.

All nine TUS extensions are now supported, including checksum-trailer — see the caveat below.

Caveat: patched Vert.x required

checksum-trailer needs a vertx-core that can read HTTP request trailers, which stock Vert.x cannot (eclipse-vertx/vert.x#5253, PR #6278). The build forces a 4.5.32-SNAPSHOT built from that change, and CI clones and installs it (cached against the branch revision). A local build needs mvn install in that checkout first. Once the change ships upstream, remove the force in build.gradle, the CI block, and the warning in configuration.adoc.

Testing

143 → 229 tests. New tests were checked against the old behaviour rather than assumed: the ownership, TOCTOU, discard-lock, XFF, completion-latch, SSE and trailer tests each fail when the corresponding fix is reverted. Three existing tests encoded non-conformant checksum behaviour and were updated; testConcurrentPatchOnSameUpload asserted an implementation detail (losers always get 423) and now asserts the real invariant plus data integrity.

Native image is not covered — that job only runs on push to master, and this branch changed exactly the machinery native builds are sensitive to (beans.xml removal, explicit indexing, four new beans/providers). Worth verifying before or immediately after merge.

jnbdz added 17 commits August 3, 2026 12:32
JaCoCo (0.8.15), awaitility (4.3.0) and microprofile-openapi-api (4.1.1)
are already at their latest stable releases.
Authorization
- Close a concatenation ownership bypass: when the ownership-checked merge
  refused, the request fell through to mergePartialUploadsUnfinished, which
  had no ownership check at all. A user could name another user's partial
  uploads, get a 201, and on the next HEAD have the victim's bytes merged
  into their own upload and the victim's partials deleted. The unfinished
  merge now takes a requiredOwnerId (SPI change), and postCreate validates
  every referenced partial up front instead of relying on a blind fallback.
- Enforce per-upload ownership on HEAD, PATCH and DELETE. getUploaderId was
  populated on create but never consulted, so a known upload ID granted full
  access to any authenticated user. Denied requests return 404 rather than
  403 so they cannot be used to probe for other users' upload IDs.
- Stop deriving the auth and rate-limit filter path from quarkus.tus.path.
  The endpoints are a constant @path, so setting that property left them in
  place but stopped the filters matching, silently serving TUS unauthenticated.
  The filters now match the real mount point and a build step rejects any
  other configured value.

Packaging
- Drop runtime META-INF/beans.xml. It made Quarkus index the whole runtime
  jar as a bean archive, defeating every conditional registration in
  TusProcessor. JAX-RS resources and providers are now indexed explicitly and
  gated on the same config as their bean registration.
- Register TusMetricsService only when Capability.METRICS is present. It
  references Micrometer types and Micrometer is compileOnly, so consumer apps
  without that extension failed to load the class.
- Remove the hard quarkus-smallrye-health-deployment dependency, which forced
  its own capability check to always be true.

Protocol conformance
- Reject PATCH on unfinished final concatenations. isFinalConcat means "merge
  still pending", so the guard's !isFinalConcat() clause let clients write
  into a placeholder that finalizeConcatenation later overwrote.
- Reject a creation-with-upload body longer than Upload-Length, and stop
  writeInitialData writing past the declared length; it used to clamp only
  the recorded offset, leaving trailing bytes and firing a false completion.
- Reject Upload-Concat values containing an unresolvable partial reference
  instead of silently merging the subset that parsed.
- Return 400 for malformed or unsupported checksums instead of accepting the
  chunk unverified or reporting 460, and parse before taking the lock so the
  new early returns cannot leak it.
- Stop advertising checksum-trailer; nothing ever populated the trailer map
  it claimed to read.
- Give merged final uploads an owner, lastActivity and expiry so they are no
  longer permanent files that no cleanup job touches.

Tests go from 143 to 154. TusOwnershipTest uses elytron properties-file basic
auth for two real principals, since @testsecurity applies one identity per
test method. Three existing tests asserted the old checksum behaviour and were
updated.
The TUS core protocol requires the server to interpret X-HTTP-Method-Override
as the request method. Without it, clients behind proxies or corporate filters
that block PATCH and DELETE cannot use the server at all: their POST with the
override header was routed to upload creation instead.

TusMethodOverrideFilter is @PreMatching, so the rewritten method drives
resource matching and every later filter — including authentication and rate
limiting — sees the effective method rather than the one on the wire. The
override is scoped to the TUS endpoints so it cannot change how the rest of
the application dispatches requests, and the target method must be one of the
standard HTTP methods; anything else is rejected with 400 rather than silently
routed. Enabled by default per the spec, and disableable via
quarkus.tus.method-override-enabled for deployments whose proxy enforces
method-based rules.

Also extracts the duplicated TUS path check into TusUtils.isTusPath, now
shared by the auth, rate-limit and method-override filters.

Tests go from 193 to 202: six conformance tests covering PATCH, HEAD, DELETE,
case-insensitive values, rejection of unsupported values and the no-header
path; one ownership test confirming the override cannot bypass the per-upload
authorization check; and two covering the disabled configuration.
The concatenation extension requires HEAD on a final upload to return the
Upload-Concat header "as received in the upload creation request", but the
value was rebuilt from the parsed partial IDs as "final;/tus/{id} ...". A
client that sent absolute URLs or different spacing got back a different
string than it sent.

Both merge methods now take the raw Upload-Concat header and store it
verbatim (SPI change). Rebuilding from the IDs remains only as a fallback for
callers that reach the SPI without a header value.

The existing test only asserted the value starts with "final;", which is why
this went unnoticed; the two new tests assert an exact echo, covering both the
complete-merge and unfinished-merge paths.
…tore

The offset was validated before the lock was acquired, so two requests could
both pass validation and then write in turn. With chunks of different sizes
the second write overwrote part of the first while both returned 204, leaving
a silently corrupted upload that no checksum could detect: each chunk is
validated in isolation. A client retrying an in-flight PATCH was enough to
trigger it, no attacker required.

The lock is now taken before any state-dependent validation, and the offset,
deferred length and content-length checks all run under it. Lock ownership
passes to the async write pipeline, whose eventually() releases it; every
earlier exit releases in a finally.

The store no longer trusts the caller's offset either. writeChunkAsync
compares it to the upload's current offset and fails with the new
spi.OffsetMismatchException, which the resource maps to 409 carrying the real
Upload-Offset so the client can resume. This matters for third-party stores,
which would otherwise inherit the assumption that the caller got it right.

Cheap stateless rejections (chunk size, malformed or unsupported checksum)
still happen before the lock so a bad request never contends for it.

testConcurrentPatchOnSameUpload asserted that all four losing requests get
423. That only held because validation preceded locking; a loser that acquires
the lock after the winner finishes now correctly sees a moved offset and gets
409, which is more useful since it carries the offset to resume from. The test
now asserts the real invariant — exactly one winner, losers rejected with 423
or 409 — and additionally that the stored bytes are one writer's chunk rather
than a blend, which is the corruption this fix prevents.
discardUpload removed the entry, deleted the data file and dropped the lock
unconditionally, without holding it. A DELETE or a cleanup job landing while a
PATCH was mid-write therefore unlinked the file underneath the in-flight
write, which continued against the now-unlinked fd and then persisted metadata
for an upload whose data file was gone — recreating a .meta that only a
restart would reap, and potentially firing TusUploadCompletedEvent for a file
that no longer existed. The client got 204 with an offset for bytes that were
never durably stored.

discardUpload now acquires the lock and returns false if it cannot, leaving
the upload untouched. DELETE answers 423 when an upload exists but is being
written; deleting something that never existed stays idempotent at 204. The
merge and finalization paths already hold every partial's lock, so they use a
private discardLockedUpload — acquisition is not reentrant, and routing them
through the locking variant makes them silently fail to delete the partials,
which the existing concatenation tests catch.

Cleanup jobs inherit the same protection for free, skipping an in-use upload
and retrying on their next run. They now also return and log only the uploads
actually removed, rather than counting skipped ones as cleaned.

writeChunkAsync additionally drops its result if the upload was discarded
while the write was in flight, so a discard that slips through some other path
cannot resurrect metadata or fire a completion event for a deleted upload.
The throttle keyed anonymous clients on the X-Forwarded-For header, which the
client controls. Varying it per request bought a fresh burst allowance every
time, making the limiter a no-op for anyone who bothered to set it, and each
distinct value also inserted a bucket into a map only swept after an hour of
idleness — so the bypass doubled as a memory-growth vector. With no header at
all, every anonymous client instead shared one "anonymous" bucket and could
lock the others out.

Clients are now identified by the authenticated principal, falling back to the
peer address of the connection. Deployments behind a reverse proxy should set
quarkus.http.proxy.proxy-address-forwarding (with trusted-proxies): Quarkus
verifies the immediate peer is a trusted proxy before resolving the forwarded
address into the request's remote address, so the throttle applies per
forwarded client without trusting arbitrary senders.

TusRateLimitTest depended on X-Forwarded-For to isolate its buckets, which is
the behaviour being removed; it now enables proxy-address-forwarding and so
covers the behind-a-proxy deployment. The new TusRateLimitSpoofingTest covers
the untrusted case: distinct forged values must not escape the burst limit.
It is deliberately a single test, since every request in that profile shares
the one bucket keyed by the peer address.

Rate limiting was undocumented; configuration.adoc now covers the three
properties and the proxy requirement.
Reaching the final offset was treated as the completion trigger, but a PATCH
at the final offset with an empty body satisfies every check on the way there:
the offset matches, nothing exceeds the declared length, the zero-length
checksum guard skips validation, and a zero-byte write succeeds. Each such
request fired TusUploadCompletedEvent again. Observers of that event typically
move the file to permanent storage, insert a row, run a virus scan, call a
webhook or bill for the upload, so any client could replay all of it
indefinitely, with no attacker sophistication required.

Completion is now latched on UploadInfo: markCompletionFired() returns true
only for the caller that makes the transition, and every firing site — the
chunk write, creation-with-upload, and the in-memory reference store — goes
through it. The latch is persisted in the .meta sidecar so a restart cannot
grant a completed upload one more event; metadata written before the field
existed defaults to unfired, which risks one extra event rather than
suppressing a genuine one.

Zero-length uploads are the reason this is a latch rather than a rejection of
PATCH on a complete upload: their offset already equals the declared length,
so an empty PATCH is their only chance to complete, and it must still work
exactly once.
Neither stream checked who was asking. Both reveal an upload's declared size
and live byte counts, and the progress endpoint did not even validate that the
path segment was a UUID, so any string became a map key. Worse, registering a
sink overwrote the map entry: subscribing to another user's upload silently
displaced the legitimate subscriber, whose connection was then unreachable and
never closed. Neither map is swept, so subscribing to attacker-chosen keys grew
them without limit.

Both endpoints now require the upload to exist and, when auth is enabled, to
belong to the caller, answering 404 in every other case so a denial cannot be
distinguished from a missing upload. The progress endpoint validates the ID
format like the event stream already did, which also bounds the maps to real
uploads.

The ownership rule moves to TusUploadAuthorizer, shared with TusUploadResource
rather than duplicated — three endpoints deciding access separately is how such
rules drift apart.

Registration also closes any sink it displaces, so a reconnecting client
replaces its own stream instead of leaking the previous connection.
Each reference in an Upload-Concat: final header was summed and copied
separately, so repeating one partial inflated it into a file many times its
size — a 10 MB partial referenced 200 times produced 2 GB from a single small
request, well under the default max-size and so passing every existing check.
The only real ceiling was the HTTP header size limit.

Repeating a partial is now rejected outright rather than de-duplicated: the
client asked for something that cannot be honoured, and silently merging a
different set than requested is how the earlier invalid-reference bug behaved.

The complete-partial path happened to already fail, because merging locks
every referenced partial and locking the same one twice does not succeed — but
resting a size guarantee on lock non-reentrancy is fragile, and the deferred
path took no locks and had nothing protecting it: it declared a final length
of the summed duplicates, claiming more bytes than were ever uploaded. That
path is what the new test for incomplete partials covers; the complete-partial
test passes today and pins the behaviour so it survives the reasoning above.

Also caps how many partials one merge may reference, via
quarkus.tus.max-concat-parts (default 1000, comfortably above what the header
size limit allows in practice).
The core protocol requires the header on every response except OPTIONS.
Resource methods set it themselves, but responses produced by the container
never reach them — a media-type mismatch on PATCH, an unsupported method — so
those went out without it.

A global response filter fills only the gaps, leaving a header a resource
already set untouched since it may legitimately differ from the configured
version. A request to a path matching no route at all is still answered by the
router before JAX-RS sees it, and remains outside this guarantee.
The checksum extension required Upload-Checksum as a header, which forces a
client streaming a large chunk to buffer the whole thing just to hash it
before sending. The checksum-trailer extension exists precisely to avoid that,
letting the value arrive after the body.

The body is fully read by the time the resource method runs, so a trailer is
available there. PATCH now falls back to the trailer when the header is
absent, and the same validation applies to both: a malformed value or an
unsupported algorithm is a 400, a mismatch is a 460 with the chunk discarded.

This depends on vertx-core being able to read request trailers, which stock
Vert.x cannot (eclipse-vertx/vert.x#5253). The build forces a vertx-core built
from the request-trailers branch; the extension is advertised only because of
that, and the documentation says so.

The tests write the chunked request onto a socket directly, since RestAssured
cannot send trailers.
The checksum-trailer extension needs a vertx-core that can read HTTP request
trailers, which stock Vert.x cannot, so the build forces a 4.5.32-SNAPSHOT
resolved from the local Maven repository. CI has no such repository and would
fail to resolve it.

Both jobs now clone the request-trailers-4.x branch and install it. The result
is cached against the branch's head revision, so the Maven build only runs
when that branch moves rather than on every push.

The whole block is marked temporary: it goes away, along with the force in
build.gradle and the warning in configuration.adoc, once the change ships
upstream in Vert.x.
It only ran on push to master, so a change that broke the native build would
surface after merging rather than on the pull request. That is a poor trade
for this extension in particular: native image depends on how beans and JAX-RS
classes are registered, and the runtime module registers them explicitly
because it ships no beans.xml.
nativeIntegrationTest set quarkus.native.enabled as a test system property,
which configures the test JVM but not the Quarkus build. quarkusBuild
therefore produced a JVM application and the task reported success in seconds
without ever invoking native-image, so the job has never verified anything.

The property now goes on the Gradle invocation so quarkusBuild produces a
native binary, and the task fails with an explanatory message if no runner
executable is present, so this cannot silently pass again.
Quarkus refuses to emit both a native image and a JAR from one build, so
quarkus.native.enabled alone fails dependency resolution for quarkusBuild.
checkout, cache, setup-java, upload-artifact and setup-gradle all targeted
Node 20, which GitHub now force-runs on Node 24 while warning that the
compatibility shim will go away.

checkout v4 to v7, setup-java v4 to v5, setup-gradle v4 to v6, cache v4 to v6,
upload-artifact v4 to v7. graalvm/setup-graalvm stays on v1, which still
tracks its current release.
@jnbdz
jnbdz merged commit 97bdc5a into master Aug 4, 2026
2 checks passed
@jnbdz
jnbdz deleted the harden-tus-security-and-upgrade-quarkus branch August 4, 2026 03:58
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.

1 participant