Skip to content

Validate header-derived file name and MIME type on create#865

Open
Schmarvinius wants to merge 2 commits into
mainfrom
fix/validate-header-derived-media-metadata
Open

Validate header-derived file name and MIME type on create#865
Schmarvinius wants to merge 2 commits into
mainfrom
fix/validate-header-derived-media-metadata

Conversation

@Schmarvinius

Copy link
Copy Markdown
Contributor

Issue

For direct media uploads the file name comes from the Content-Disposition / slug header and the MIME type from the Content-Type header. CreateAttachmentEvent applied this header fallback after @Core.AcceptableMediaTypes validation had already run over the (header-less) payload.

As a result a create that supplied the file name / MIME type only via headers could bypass the media type allowlist, while storage still derived and persisted those header values — the same values later served inline.

Fix

  • Extracted the header resolution logic into HeaderMediaMetadataResolver (extractFileNameFromHeader, extractMimeTypeFromHeader, applyHeaderFallback).
  • CreateAttachmentsHandler.processBeforeForMetadata now normalizes the header-derived file name / MIME type into the request data before validation, so the allowlist is enforced against the exact values that will be persisted and served.
  • CreateAttachmentEvent reuses the same resolver (no behavioral change to its own fallback).

Tests

  • HeaderMediaMetadataResolverTest: header extraction and applyHeaderFallback (fills when absent, never overrides payload values, no-op without content).
  • CreateAttachmentsHandlerTest: header fallback is applied before validation.

Note: unit tests are used here because the create-with-media-content + header flow is not reproducible through the existing MockMvc integration harness (media content is set via PUT on existing entities). The existing CreateAttachmentEventTest continues to cover header extraction end-to-end for the event.

CreateAttachmentEvent falls back to the Content-Disposition/slug and
Content-Type headers for the file name and MIME type. This fallback ran
AFTER acceptable-media-type validation, so a direct media upload that
supplied the file name / MIME type only via headers bypassed
@Core.AcceptableMediaTypes while storage still derived and persisted
those header values.

The header fallback logic is extracted into HeaderMediaMetadataResolver
and now also applied to the request data before validation, so the
allowlist is enforced against the exact values that will be persisted and
served. CreateAttachmentEvent reuses the same resolver.
Adds HeaderMediaMetadataResolverTest verifying that file name and MIME
type are resolved from the Content-Disposition/slug and Content-Type
headers and filled into the attachment data (without overriding payload
values), and a CreateAttachmentsHandler test asserting the header
fallback is applied before acceptable-media-type validation.
@Schmarvinius
Schmarvinius requested a review from a team as a code owner July 21, 2026 10:33
@hyperspace-pr-bot

Copy link
Copy Markdown
Contributor

Summary

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


Fix: Validate Header-Derived File Name and MIME Type Before @Core.AcceptableMediaTypes Check

Bug Fix

🐛 Resolved a security gap where direct media uploads providing file name and MIME type exclusively via HTTP headers (Content-Disposition/slug and Content-Type) could bypass the @Core.AcceptableMediaTypes allowlist validation. The header values were applied as a fallback after validation had already run, while storage still derived and persisted those same header values — which were later served inline.

The fix ensures header-derived metadata is normalized into the request data before validation runs, so the allowlist is enforced against the exact values that will be persisted and served.

Changes

  • HeaderMediaMetadataResolver.java (new): Extracted header resolution logic into a dedicated utility class. Provides extractFileNameFromHeader (supporting RFC 5987 encoded and plain Content-Disposition, with slug fallback), extractMimeTypeFromHeader (strips charset/parameters), and applyHeaderFallback (fills fileName/mimeType in attachment data only when absent — never overrides payload values).

  • CreateAttachmentsHandler.java: processBeforeForMetadata now calls HeaderMediaMetadataResolver.applyHeaderFallback before AttachmentValidationHelper.validateMediaAttachments, ensuring the acceptable-media-type check sees the header-resolved values.

  • CreateAttachmentEvent.java: Removed duplicated header extraction logic (RFC 5987 patterns, extractFileNameFromHeader, extractMimeTypeFromHeader) and replaced with calls to HeaderMediaMetadataResolver. No behavioral change to the event's own fallback logic.

  • HeaderMediaMetadataResolverTest.java (new): Unit tests covering header extraction, applyHeaderFallback filling absent fields, non-override of existing payload values, and no-op when no media content element is present.

  • CreateAttachmentsHandlerTest.java: Added test verifying that HeaderMediaMetadataResolver.applyHeaderFallback is called before AttachmentValidationHelper.validateMediaAttachments in processBeforeForMetadata.


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

Version: 1.28.2

  • Correlation ID: a08b9b30-84ef-11f1-8b3f-4cb56ca6c8fd
  • Summary Prompt: Default Prompt
  • Event Trigger: pull_request.opened
  • Output Template: Default Template
  • LLM: anthropic--claude-4.6-sonnet
  • File Content Strategy: Full file content

@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.

The PR is a clean refactoring with a meaningful security fix: header-derived metadata is now normalized into the data before acceptable-media-type validation runs, closing the bypass window. Three issues were flagged: (1) repeated regex matching per attachment row inside the validator lambda — the header values should be resolved once before the CdsDataProcessor loop; (2) the RFC 5987 decoded filename is not trimmed before URLDecoder.decode, potentially including trailing whitespace/CR characters unlike the plain-filename branch; and (3) the new handler test asserts call-order only weakly via MockedStatic.verify without an InOrder constraint, so ordering is not actually enforced.

PR Bot Information

Version: 1.28.2

  • Correlation ID: a08b9b30-84ef-11f1-8b3f-4cb56ca6c8fd
  • Agent Instructions:
  • Event Trigger: pull_request.opened
  • LLM: anthropic--claude-4.6-sonnet
  • File Content Strategy: Full file content

Comment on lines +54 to +68
CdsDataProcessor.create()
.addValidator(
ApplicationHandlerHelper.MEDIA_CONTENT_FILTER,
(path, element, value) -> {
Map<String, Object> values = path.target().values();
if (values.get(MediaData.FILE_NAME) == null) {
extractFileNameFromHeader(eventContext)
.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
}
if (values.get(MediaData.MIME_TYPE) == null) {
extractMimeTypeFromHeader(eventContext)
.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
}
})
.process(data, entity);

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.

Logic Error: applyHeaderFallback calls extractFileNameFromHeader and extractMimeTypeFromHeader once per matching content element, but these methods always read from the same headers — the same header values will be applied identically to every attachment. For a bulk create with multiple attachments having different values, this is fine by design, but the real problem is that the two extract* calls happen inside the validator lambda, meaning the headers are re-read (and regex matched) on every single attachment row. For requests with many attachments this is wasteful, but more importantly it is logically cleaner and efficient to resolve the header values once outside the lambda and close over them.

Consider extracting the header lookups before the CdsDataProcessor call:

    Optional<String> headerFileName = extractFileNameFromHeader(eventContext);
    Optional<String> headerMimeType = extractMimeTypeFromHeader(eventContext);

    if (headerFileName.isEmpty() && headerMimeType.isEmpty()) {
      return;
    }

    CdsDataProcessor.create()
        .addValidator(
            ApplicationHandlerHelper.MEDIA_CONTENT_FILTER,
            (path, element, value) -> {
              Map<String, Object> values = path.target().values();
              if (values.get(MediaData.FILE_NAME) == null) {
                headerFileName.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
              }
              if (values.get(MediaData.MIME_TYPE) == null) {
                headerMimeType.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
              }
            })
        .process(data, entity);

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

// Try RFC 5987 encoded filename first (filename*=UTF-8''...)
Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header);
if (utf8Matcher.find()) {
return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8));

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: The RFC 5987 pattern filename\\*=UTF-8''([^;]+) does not strip trailing whitespace from the captured group before passing it to URLDecoder.decode. If the header contains trailing spaces or a trailing \r (as is common in raw HTTP headers), the decoded file name will include them.

Should call .trim() on the captured group before decoding, consistent with how the plain-filename branch handles trimming:

        return Optional.of(URLDecoder.decode(utf8Matcher.group(1).trim(), StandardCharsets.UTF_8));
Suggested change
return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8));
return Optional.of(URLDecoder.decode(utf8Matcher.group(1).trim(), StandardCharsets.UTF_8));

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

Comment on lines +382 to +394
try (MockedStatic<HeaderMediaMetadataResolver> resolver =
mockStatic(HeaderMediaMetadataResolver.class);
MockedStatic<AttachmentValidationHelper> helper =
mockStatic(AttachmentValidationHelper.class)) {
// when
new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime)
.processBeforeForMetadata(context, data);

// then header-derived metadata is normalized into the data and validation runs over it
resolver.verify(() -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context));
helper.verify(
() -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime));
}

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 existing processBeforeForMetadata_executesValidation test still mocks HeaderMediaMetadataResolver only implicitly (it does not mock it at all), so when both tests run the MockedStatic<HeaderMediaMetadataResolver> from the new test is unrelated to the old one. However, the old test now implicitly calls the real applyHeaderFallback with a mocked CdsEntity and mocked CdsData — this could silently succeed or fail in unexpected ways as the real implementation evolves. The two tests are also partially redundant: the ordering assertion in the new test (fallback before validation) is only weakly enforced by call-order in MockedStatic.verify, which does not actually assert invocation order.

Consider using InOrder from Mockito to assert that applyHeaderFallback is called before validateMediaAttachments, making the ordering guarantee explicit and reliable:

    try (MockedStatic<HeaderMediaMetadataResolver> resolver =
            mockStatic(HeaderMediaMetadataResolver.class);
        MockedStatic<AttachmentValidationHelper> helper =
            mockStatic(AttachmentValidationHelper.class)) {
      InOrder inOrder = inOrder(resolver, helper);
      // when
      new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime)
          .processBeforeForMetadata(context, data);

      // then header-derived metadata is normalized into the data and validation runs over it
      inOrder.verify(resolver, () -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context));
      inOrder.verify(helper, () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime));
    }

Note: MockedStatic.InOrder is available since Mockito 4.x via MockedStatic#inOrder; if that API is unavailable here, an AtomicInteger counter can enforce the order.


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