Skip to content

Enforce rescan-on-download for all content read shapes#868

Open
Schmarvinius wants to merge 3 commits into
mainfrom
fix/enforce-rescan-on-all-content-reads
Open

Enforce rescan-on-download for all content read shapes#868
Schmarvinius wants to merge 3 commits into
mainfrom
fix/enforce-rescan-on-all-content-reads

Conversation

@Schmarvinius

Copy link
Copy Markdown
Contributor

Issue

The stale-clean rescan-on-download check in ReadAttachmentsHandler.verifyStatus only ran for content-only reads (empty target keys). Keyed reads that stream content went through LazyProxyInputStream, which validated only the stored status (blocking INFECTED) but skipped the scannedAt freshness check. A stale-clean attachment could therefore be downloaded via a keyed path without triggering a rescan.

Fix

The status + freshness policy is encapsulated in a new ContentReadGuard that LazyProxyInputStream invokes right before serving bytes, so every read shape (content-only $value and keyed content reads) enforces the same rescan-on-download and status validation.

  • ReadAttachmentsHandler builds a guard per attachment (enforceReadPolicy) and keeps the eager content-only check (verifyStatus) so those responses are still blocked before serialization.
  • LazyProxyInputStream now takes a ContentReadGuard instead of a status string + validator.

Metadata-only reads remain cheap: the guard only runs when content bytes are actually read.

Tests

  • ReadAttachmentsHandlerTest: keyed stale-clean attachment triggers rescan and blocks on content read; keyed infected attachment blocked on content read.
  • LazyProxyInputStreamTest: updated for ContentReadGuard (guard verified before serving, blocks on throw).

The stale-clean rescan-on-download check only ran for content-only reads
(empty target keys). Keyed reads that stream content went through
LazyProxyInputStream, which validated only the stored status and skipped
the scannedAt freshness check, so a stale clean attachment could be
downloaded through a keyed path without triggering a rescan.

The status and freshness policy is now encapsulated in a ContentReadGuard
that LazyProxyInputStream invokes before serving bytes, so every read
shape enforces the same rescan-on-download and status validation. The
eager content-only check is retained so those responses are still blocked
before serialization.
Adds ReadAttachmentsHandler tests asserting that a keyed read of a
stale-clean attachment triggers the rescan and blocks the download when
the content is actually read, and that a keyed infected attachment is
blocked on content read. Updates LazyProxyInputStreamTest for the new
ContentReadGuard.
@Schmarvinius
Schmarvinius requested a review from a team as a code owner July 21, 2026 10:49
@hyperspace-pr-bot

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Enforce Rescan-on-Download for All Content Read Shapes

Bug Fix

🐛 Fixed a security gap where the malware scan freshness check (rescan-on-download) was only enforced for content-only ($value) reads. Keyed reads that stream attachment content bypassed the scannedAt staleness check via LazyProxyInputStream, meaning a stale-clean attachment could be downloaded without triggering a rescan.

Changes

The fix introduces a ContentReadGuard abstraction that encapsulates the status and freshness policy and is invoked by LazyProxyInputStream right before serving bytes, ensuring every read shape is protected uniformly.

  • ContentReadGuard.java: New @FunctionalInterface that acts as a guard invoked by LazyProxyInputStream before content bytes are served. Implementations throw AttachmentStatusException to block downloads when content is not clean or requires a rescan.

  • LazyProxyInputStream.java: Replaced the previous AttachmentStatusValidator + status string fields with a single ContentReadGuard. The constructor signature is simplified, and the guard's verify() method is called in getDelegate() before any bytes are served.

  • ReadAttachmentsHandler.java:

    • Extracted the scan status and freshness logic into a new enforceReadPolicy(CdsEntity, Attachments) method that handles rescan triggering and status validation for all read paths.
    • verifyStatus (eager check for content-only reads) now delegates to enforceReadPolicy.
    • LazyProxyInputStream is now constructed with a lambda wrapping enforceReadPolicy, ensuring keyed content reads are also guarded lazily.
  • ReadAttachmentsHandlerTest.java: Added two new tests:

    • keyedStaleCleanAttachmentIsRescannedWhenContentIsRead: verifies that a stale-clean keyed attachment triggers a rescan and blocks the download on content read.
    • keyedInfectedAttachmentIsBlockedWhenContentIsRead: verifies that an infected keyed attachment is blocked when content is actually read.
  • LazyProxyInputStreamTest.java: Updated tests to use ContentReadGuard mock instead of AttachmentStatusValidator + status string. Added guardIsVerifiedBeforeContentIsServed and exceptionIfGuardBlocksRead tests, replacing the previous parameterized status-based tests.


  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.28.2

  • LLM: anthropic--claude-4.6-sonnet
  • Output Template: Default Template
  • Correlation ID: d9c531c0-84f1-11f1-9ab7-cac9cf9afaf1
  • File Content Strategy: Full file content
  • Summary Prompt: Default Prompt
  • Event Trigger: pull_request.opened

@hyperspace-pr-bot hyperspace-pr-bot Bot 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.

Summary

The PR correctly fixes the core security gap — keyed content reads now enforce the same rescan-on-download freshness policy as content-only reads. The design is clean and the ContentReadGuard abstraction is well-motivated.

Two issues were flagged:

  1. Critical bug in LazyProxyInputStream.getDelegate(): contentReadGuard.verify() runs unconditionally on every read() call rather than only before the delegate is first initialized. This causes enforceReadPolicy (and potentially transitionToScanning + scanAsync) to execute once per read chunk — which is both a correctness problem and a significant performance regression for large file downloads. The fix is to move contentReadGuard.verify() inside the delegate == null guard.

  2. Missing test coverage: The supplierOnlyCalledOnce test verifies the supplier is called once but doesn't assert the guard is also called only once, leaving the repeated-invocation bug undetected by the test suite.

PR Bot Information

Version: 1.28.2

  • LLM: anthropic--claude-4.6-sonnet
  • Agent Instructions:
  • Correlation ID: d9c531c0-84f1-11f1-9ab7-cac9cf9afaf1
  • File Content Strategy: Full file content
  • Event Trigger: pull_request.opened


private InputStream getDelegate() {
attachmentStatusValidator.verifyStatus(status);
contentReadGuard.verify();

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.

Bug: contentReadGuard.verify() is invoked on every read call, not just the first.

getDelegate() calls contentReadGuard.verify() unconditionally each time, while the delegate-initialization guard (delegate == null) is right below it. For a single content download this means enforceReadPolicy runs once per read()/read(byte[]) call, re-executing the staleness check, potentially calling transitionToScanning and scanAsync again on each chunk. The guard should only fire once — before the delegate is initialized.

Suggested change
contentReadGuard.verify();
private InputStream getDelegate() {
if (delegate == null) {
contentReadGuard.verify();
logger.debug("Creating delegate input stream");
delegate = inputStreamSupplier.get();
}
return delegate;
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

* Enforces the status and freshness policy before content is served.
*
* @throws AttachmentStatusException if the content must not be served (e.g. not clean or a rescan
* has been triggered)

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.

Best Practice: The Javadoc @throws tag references AttachmentStatusException without importing or qualifying it, so the cross-reference is broken in generated Javadoc. Use a fully-qualified name or add the import to make the link resolvable.

Suggested change
* has been triggered)
* @throws com.sap.cds.feature.attachments.handler.applicationservice.readhelper.AttachmentStatusException if the content must not be served (e.g. not clean or a rescan
* has been triggered)

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

cut.read();

verify(contentReadGuard).verify();
verify(attachmentService).readAttachment(any());

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.

Bug: supplierOnlyCalledOnce does not verify that the guard is also invoked only once across two reads. With the current implementation contentReadGuard.verify() fires on every read() call (once per chunk), masking the repeated-guard-invocation bug. A verify(contentReadGuard, times(1)).verify() assertion should be added here.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

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