Repository navigation
bug: consume request body before sending response - #2066
Conversation
As otherwise MITM proxies may translate this as server error and produce 50x errors.
There was a problem hiding this comment.
Pull request overview
This PR improves HTTP semantics and proxy/LB compatibility around personal access token (PAT) validation, ensuring invalid/expired tokens yield 401 Unauthorized and that publish requests drain the request body before responding with an error.
Changes:
- Update PAT handling to treat expired tokens as invalid during
useAccessToken. - Return
401 Unauthorized(instead of400 Bad Request) for invalid/expired tokens on namespace creation and publishing. - Extend API tests to cover expired-token scenarios for namespace creation and publishing.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…n too, correct 401/403 Two follow-ups to review feedback on this PR: - AccessTokenService#useAccessToken is now @transactional(TxType.REQUIRES_NEW). Without it, the setActive(false)/setAccessedTimestamp mutations joined the caller's transaction and were rolled back whenever createNamespace threw its own (expected) ErrorResultException right after - silently discarding the fact that an expired token was ever touched. Matches the same REQUIRES_NEW idiom already used by ExtensionScanPersistenceService for an identical reason. - LocalRegistryService#publish now drains the request body before rejecting for a missing publisher agreement, not just for an invalid/expired token - eclipse.checkPublisherAgreement(...) can throw before content has been touched at all, reproducing the same early-response-unread-body problem this PR set out to fix. Also audited 401 vs 403 usage across the token/permission code paths this PR touches and found the same 'defaults to 400' gap in several more places: - LocalRegistryService#verifyToken: a third invalid-token call site this PR didn't touch, now 401 for consistency with the other two; its own 'insufficient access rights' check is now 403 (authenticated, not permitted). - PublishExtensionVersionHandler#checkPublishPermission (shared by both the scan and non-scan publish paths): same 'insufficient access rights' fix, 403. - EclipseService#checkPublisherAgreement (all 4 throw sites): 403, matching the sibling checkEclipseToken's existing 403 in the same file. Updated the RegistryAPITest assertions that hard-coded the old status, and the OpenAPI docs on verifyToken/publish, which were stale either way (the /api/-/publish 400 doc still showed 'Invalid access token' as its example despite this PR already making that case 401). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses Copilot review feedback: the new expiration check used isBefore(now), treating a token expiring at exactly now as still valid. The rest of the codebase treats expiry as inclusive - expireAccessTokens' "expires_timestamp <= ?1" and findByExpiresTimestampLessThanEqualAndActiveTrueAndNotifiedFalseOrderById both use <=. Switch to !isAfter(now) to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The manual try/catch-and-drain in publish()'s catch block only covered the two rejection sites it explicitly wrapped, and didn't help with the pre-existing gap in ExtensionService#createExtensionFile (an oversized-package rejection only reads up to maxContentSize + 1 bytes, leaving anything beyond the cap unread). Add DrainOnCloseInputStream, a small FilterInputStream that drains any remaining unread bytes in close(). Wrap the request body with it once, at the top of publish(), and rely on try-with-resources to close it on every exit path - success, an early rejection, or a validation failure deep inside extensions.publishVersion(...). This replaces the ad hoc drain-on-catch with a structural guarantee that can't be forgotten by a future validation check, and - confirmed with a test mirroring createExtensionFile's exact BufferedInputStream/size-capped-view chain - also fixes the oversized-package gap, without ExtensionService needing to know about it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
server/src/main/java/org/eclipse/openvsx/util/DrainOnCloseInputStream.java:45
- DrainOnCloseInputStream is described/used as a best-effort wrapper, but if the underlying stream throws on close(), the IOException will currently propagate (from super.close()) and can fail the request even after successful processing. Swallow IOException from the delegated close as well so draining/closing never changes the outcome of the request.
public void close() throws IOException {
try {
transferTo(OutputStream.nullOutputStream());
} catch (IOException ignored) {
// best-effort drain; connection state doesn't matter once we're closing this stream
server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java:811
- The linked issue (EclipseFdn/open-vsx.org#12463) has acceptance criteria calling for HTTP 403 on expired/invalid keys, but this change returns 401 (UNAUTHORIZED) for an invalid/expired token. Please confirm the intended status code for PAT failures and align either the implementation/tests or the issue requirements.
var token = tokens.useAccessToken(tokenValue);
if (token == null || token.getUser() == null) {
throw new ErrorResultException(ACCESS_TOKEN_ERROR, HttpStatus.UNAUTHORIZED);
}
…over the size-limit test Addresses two follow-ups from review: - DrainOnCloseInputStream capped close()'s drain at maxBytes without accounting for bytes a caller already read normally beforehand. ExtensionService#createExtensionFile's own oversized-package detection already reads up to maxContentSize + 1 bytes before rejecting; without tracking cumulative reads, close() would then drain a fresh maxBytes on top, costing up to ~2x maxBytes total for exactly the case the bound exists to protect against. Now tracks bytesRead across the stream's whole lifetime (matching the existing SizeLimitInputStream in the same package) and only drains the remaining budget at close. Verified the regression test actually catches this: reverted the fix, watched it fail with the exact double-counted numbers, restored it. - Added ExtensionServiceTest#shouldRejectAPackageExceedingTheMaxContentSize: no existing test exercised createExtensionFile's size-limit rejection itself (the one stub of getMaxContentSize() in this test class was incidental, for an unrelated precondition-check test). Verified empirically (mirroring the exact BufferedInputStream/ByteStreams.limit chain createExtensionFile builds) that reading past the cap through DrainOnCloseInputStream does not throw - only close() enforces the bound - so wrapping the test's content in DrainOnCloseInputStream with the same cap LocalRegistryService#publish uses makes the test match the real call path without changing its outcome. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java:1938
- This test uses LocalDateTime.now(), but token expiration is evaluated against TimeUtil.getCurrentUTC() in AccessTokenService. Using the system-default clock here can make the test timezone-dependent/flaky; use TimeUtil.getCurrentUTC() for consistency.
token.setExpiresTimestamp(LocalDateTime.now().minusDays(1));
server/src/main/java/org/eclipse/openvsx/util/DrainOnCloseInputStream.java:36
- The Javadoc claims the total bytes read over the stream’s lifetime is capped at maxBytes, but the implementation only bounds how many additional bytes close() will drain (it doesn’t prevent callers from reading past maxBytes). This can mislead future callers about the guarantee.
* The <b>total</b> bytes ever read from the underlying stream over this instance's lifetime -
* whatever a caller reads normally before rejecting, plus whatever close() then drains - is capped
* at {@code maxBytes}: without a bound, a client rejected for any reason (invalid token, an
* oversized upload, ...) could force the server to read and discard an arbitrarily large body
* before responding, trading the original LB/proxy problem for a resource-exhaustion one.
server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java:1711
- This test uses LocalDateTime.now(), but token expiration is evaluated against TimeUtil.getCurrentUTC() in AccessTokenService. Using the system-default clock here can make the test timezone-dependent/flaky; use TimeUtil.getCurrentUTC() for consistency.
This issue also appears on line 1938 of the same file.
token.setExpiresTimestamp(LocalDateTime.now().minusDays(1));
- DrainOnCloseInputStream's Javadoc claimed the total bytes read over the stream's lifetime is capped at maxBytes, but the class doesn't enforce a hard limit on normal reads - only close()'s own drain is bounded to whatever remains of the budget. Reworded to say that precisely: the total only stays near maxBytes in combination with a caller (like ExtensionService#createExtensionFile) that itself stops reading once it's seen enough to reject the request. - testCreateNamespaceExpiredToken/testPublishExpiredToken used LocalDateTime.now() to set an expired timestamp, while AccessTokenService evaluates expiry against TimeUtil.getCurrentUTC(). Not an actual flakiness risk in practice (minusDays(1) stays in the past under any real-world timezone offset), but a real inconsistency with what the code under test actually uses. Switched both to TimeUtil.getCurrentUTC(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

As otherwise LB/proxies may translate this as server error and produce 50x errors downstream.
Changes:
Fixes EclipseFdn/open-vsx.org#12463
Note: Changed code is/will conflict with trusted publishing, where even the "verify" use case has been totally separated out. This PR merely copies the applicable bits from trusted publishing
AccessTokenServiceand adds body consumption before throwing error (and modifies error from 400 to 401).