Skip to content

fix(provider-utils): bound media-type sniffing decode for ID3-prefixed input - #17386

Merged
dnukumamras merged 4 commits into
mainfrom
dnukumamras/trim-inline-comments
Jul 21, 2026
Merged

fix(provider-utils): bound media-type sniffing decode for ID3-prefixed input#17386
dnukumamras merged 4 commits into
mainfrom
dnukumamras/trim-inline-comments

Conversation

@dnukumamras

@dnukumamras dnukumamras commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Media-type sniffing is documented as an O(1) check on the first ~18 bytes (24 base64 chars). An ID3/SUQz-prefixed attachment bypassed that bound: stripID3TagsIfPresent ran before the truncation and decoded the entire base64 attachment (plus a full-size .slice() copy), then returned a Uint8Array so the 24-char cap was skipped. A 4-character prefix flipped intended O(1) sniffing into an O(N) decode + copy of the whole attachment, on every path including image detection.

Hardening

  • Decode a bounded prefix first, then strip ID3 within that bound, so no input can force a full decode.
  • Detection now runs on decoded bytes (ID3) instead of the base64 string (startsWith('SUQz')), so string and raw-byte inputs are checked uniformly and base64 alignment tricks cannot fool it.
  • The raw-Uint8Array path is bounded too, and stripID3 uses subarray (a view) instead of slice (a full copy), removing the second O(N) copy.
  • ID3 skip is capped at ID3_MAX_SCAN_BYTES (128 KB), large enough to cover typical tags with embedded cover art while keeping cost constant in attachment size.

Happy path

  • Non-ID3 attachments (PNG, JPEG, WEBP, PDF, WAV, and so on) decode only ~18 bytes exactly as before, so common detection is unchanged.
  • MP3s with an ID3v2 tag up to 128 KB, including typical embedded cover art, still auto-detect as audio/mpeg.
  • data: URLs, base64url, ArrayBuffer, Buffer, and text inputs are normalized upstream and behave the same.

Unhappy path

  • An ID3/SUQz-prefixed attachment (benign or crafted) now decodes at most ~128 KB regardless of attachment size, so sniffing stays constant-time instead of scaling with the input.
  • An ID3v2 tag larger than 128 KB is no longer scanned past, so such an MP3 is not auto-detected and falls back to the caller-supplied media type (resolveFullMediaType throws its existing "must specify subtype" error if none was provided). Raise ID3_MAX_SCAN_BYTES if larger tags must be detected; cost stays O(1) either way.
  • A crafted oversized id3Size cannot read out of bounds: subarray clamps to length and returns empty, so no signature matches and detection returns undefined.

Testing

  • Added a regression test that an ID3 tag larger than the scan bound is not detected (locks in the O(1) behavior).
  • Full detect-media-type suite passes (67 tests).

Fixes #17709

…d input

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lgrammel

Copy link
Copy Markdown
Collaborator

Bugfix review

Outcome: changes-required

Fixes issue

Status: partially-addresses

The implementation removes the attachment-sized decode and copy, but it does not preserve the claimed detection behavior for ID3 tags up to the 128 KiB limit.

Concerns:

  • The bounded prefix includes the ID3 tag itself but reserves no bytes after it for the MP3 signature. A tag ending at the limit therefore cannot be detected.
  • Focused boundary checks found that a tag ending one byte below the limit returns undefined for Uint8Array input but audio/mpeg for equivalent base64 input, contradicting the claimed uniform behavior.

Side effects

Risk: low

Legitimate MP3 inputs near the new scan boundary can stop auto-detecting, with representation-dependent results at one boundary value.

Concerns:

  • Previously detectable MP3 data near the cutoff may now require an explicitly supplied full media type.

Performance

Risk: low

Work is now bounded independently of attachment size, and subarray avoids copying raw input; ID3-prefixed base64 still incurs a bounded allocation of roughly 128 KiB.

Backwards compatibility

Risk: low

The change does not mutate stored data, but existing stored MP3 attachments near the cutoff can produce different detection results or fail subtype resolution.

Concerns:

  • Callers relying on automatic subtype resolution may receive the existing unsupported-functionality error for affected inputs.

Security

Risk: none

The change reduces denial-of-service exposure by replacing attachment-sized decoding and copying with a fixed upper bound and introduces no new privileged operations.

Testing

Status: needs-more

The 67-test detection suite passes in Node and Edge, but the added test covers only an oversized tag and misses the failing cutoff and representation-parity cases.

Concerns:

  • Add tests for the largest supported tag with post-tag signature bytes, including the exact cutoff, for both Uint8Array and base64 inputs.
  • The oversized-tag assertion does not directly verify that a large attachment with a normal small ID3 tag is decoded only to the bounded prefix; an implementation that fully decoded and then rejected by tag size could still pass it.

Verification

Inspected the merge-base diff and media-type callers, ran the complete detect-media-type suite in Node and Edge, package type-checking, formatting and lint checks, and focused boundary probes. Standard checks passed, while boundary probes exposed missed detection at the advertised limit and a raw/base64 mismatch.

…ID3 limit detectable

Trim the base64 decode to exactly the requested byte count so string and
raw-byte inputs sniff identically, and reserve signature room past the ID3 tag
so a tag at the size limit still matches. Add exact-cutoff parity tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dnukumamras

Copy link
Copy Markdown
Contributor Author

Thanks, both concerns were real and are fixed in 947aabd.

Signature headroom at the limit

The scan bound was the total decoded prefix, but stripID3 returns the bytes after the tag, so a tag filling the budget left nothing to match. Split the budget:

  • MAX_ID3_TAG_BYTES (128 KiB) is the largest tag we skip.
  • ID3_SCAN_BYTES = MAX_ID3_TAG_BYTES + MAX_SIGNATURE_BYTES is what we actually decode, reserving room for the trailing signature.

Cutoff is now exact and testable: a tag of MAX_ID3_TAG_BYTES detects, MAX_ID3_TAG_BYTES + 1 does not.

Base64 vs Uint8Array parity

Root cause: Math.ceil(maxBytes / 3) * 4 rounds up to a whole 4-char group, so the base64 path decoded up to 2 bytes more than the exact subarray on the raw-byte path, which is the one-byte mismatch you saw at the boundary. decodePrefix now trims the decoded bytes to exactly maxBytes, so both representations produce the same length and sniff identically.

Tests

  • Largest supported tag with the frame right after it, asserted for both Uint8Array and base64 (exact cutoff + parity).
  • One byte past the limit, asserted undefined for both. This also proves the decode stops at the bound: an implementation that fully decoded and then rejected by tag size would still find the frame here and return audio/mpeg, so this case fails that implementation.
  • 68 tests pass.

Side-effect note stands

An ID3v2 tag larger than 128 KiB is still not scanned past, so such an MP3 falls back to the caller-supplied media type (resolveFullMediaType throws its existing unsupported-subtype error if none was given). MAX_ID3_TAG_BYTES can be raised if needed; cost stays O(1) in attachment size either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lgrammel

Copy link
Copy Markdown
Collaborator

Bugfix review

Outcome: approved

Fixes issue

Status: fully-addresses

Media sniffing now decodes only a fixed-size prefix, uses bounded subarray views for byte input, preserves signature headroom after supported ID3 tags, and produces matching results for raw-byte and base64 representations at the cutoff.

Side effects

Risk: low

Normal media signatures and ID3-tagged MP3s through the documented 128 KiB limit remain detectable; larger ID3 tags now intentionally require a caller-supplied full media type.

Concerns:

  • Previously auto-detected MP3 attachments with ID3 tag bodies exceeding 128 KiB will no longer be auto-detected.

Performance

Risk: none

Attachment-sized base64 decoding and copying are eliminated. ID3-prefixed base64 input incurs only a bounded decode of roughly 128 KiB, while raw input uses non-copying subarray views.

Backwards compatibility

Risk: low

The change does not mutate stored data, although stored MP3 attachments with oversized ID3 tags may now fail automatic subtype resolution when reused without a full media type.

Concerns:

  • Callers processing ID3 tags beyond the new limit must explicitly supply audio/mpeg or another full subtype.

Security

Risk: none

The change reduces denial-of-service exposure by bounding work independently of attachment size and introduces no new privileged operations or unsafe parsing.

Testing

Status: appropriate

Regression coverage includes ordinary ID3 MP3s, the exact supported cutoff, one byte beyond it, and raw-byte/base64 parity. Both Node and Edge provider-utils suites passed.

Verification

Inspected the merge-base diff, callers, changeset, and follow-up boundary fix; ran the complete provider-utils Node and Edge suites, package type checking, repository formatting and lint checks, diff validation, and a focused instrumentation probe confirming that a multi-megabyte ID3-prefixed base64 attachment is decoded only through the fixed prefix.

@dnukumamras
dnukumamras merged commit 02ffdcb into main Jul 21, 2026
55 checks passed
@dnukumamras
dnukumamras deleted the dnukumamras/trim-inline-comments branch July 21, 2026 05:44
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Published in:

Package Version
ai 7.0.33 github npm
@ai-sdk/alibaba 2.0.16 github npm
@ai-sdk/amazon-bedrock 5.0.26 github npm
@ai-sdk/angular 3.0.33 github npm
@ai-sdk/anthropic 4.0.17 github npm
@ai-sdk/anthropic-aws 2.0.9 github npm
@ai-sdk/assemblyai 3.0.12 github npm
@ai-sdk/azure 4.0.18 github npm
@ai-sdk/baseten 2.0.14 github npm
@ai-sdk/black-forest-labs 2.0.12 github npm
@ai-sdk/bytedance 2.0.14 github npm
@ai-sdk/cartesia 3.0.6 github npm
@ai-sdk/cerebras 3.0.14 github npm
@ai-sdk/cohere 4.0.12 github npm
@ai-sdk/deepgram 3.0.12 github npm
@ai-sdk/deepinfra 3.0.14 github npm
@ai-sdk/deepseek 3.0.13 github npm
@ai-sdk/elevenlabs 3.0.13 github npm
@ai-sdk/fal 3.0.13 github npm
@ai-sdk/fireworks 3.0.15 github npm
@ai-sdk/gateway 4.0.25 github npm
@ai-sdk/gladia 3.0.12 github npm
@ai-sdk/google 4.0.20 github npm
@ai-sdk/google-vertex 5.0.24 github npm
@ai-sdk/groq 4.0.13 github npm
@ai-sdk/harness 1.0.38 github npm
@ai-sdk/harness-claude-code 1.0.39 github npm
@ai-sdk/harness-codex 1.0.40 github npm
@ai-sdk/harness-deepagents 1.0.37 github npm
@ai-sdk/harness-opencode 1.0.39 github npm
@ai-sdk/harness-pi 1.0.38 github npm
@ai-sdk/huggingface 2.0.14 github npm
@ai-sdk/hume 3.0.12 github npm
@ai-sdk/klingai 4.0.13 github npm
@ai-sdk/langchain 3.0.33 github npm
@ai-sdk/llamaindex 3.0.33 github npm
@ai-sdk/lmnt 3.0.12 github npm
@ai-sdk/luma 3.0.13 github npm
@ai-sdk/mcp 2.0.16 github npm
@ai-sdk/mistral 4.0.14 github npm
@ai-sdk/moonshotai 3.0.17 github npm
@ai-sdk/open-responses 2.0.12 github npm
@ai-sdk/openai 4.0.17 github npm
@ai-sdk/openai-compatible 3.0.14 github npm
@ai-sdk/otel 1.0.33 github npm
@ai-sdk/perplexity 4.0.13 github npm
@ai-sdk/policy-opa 1.0.33 github npm
@ai-sdk/prodia 2.0.13 github npm
@ai-sdk/provider-utils 5.0.12 github npm
@ai-sdk/quiverai 2.0.12 github npm
@ai-sdk/react 4.0.36 github npm
@ai-sdk/replicate 3.0.13 github npm
@ai-sdk/revai 3.0.12 github npm
@ai-sdk/rsc 3.0.33 github npm
@ai-sdk/sandbox-just-bash 1.0.38 github npm
@ai-sdk/sandbox-vercel 1.0.38 github npm
@ai-sdk/svelte 5.0.33 github npm
@ai-sdk/togetherai 3.0.15 github npm
@ai-sdk/tui 1.0.34 github npm
@ai-sdk/valibot 3.0.12 github npm
@ai-sdk/vercel 3.0.14 github npm
@ai-sdk/voyage 2.0.12 github npm
@ai-sdk/vue 4.0.33 github npm
@ai-sdk/workflow 1.0.33 github npm
@ai-sdk/workflow-harness 1.0.38 github npm
@ai-sdk/xai 4.0.18 github npm

lgrammel added a commit that referenced this pull request Jul 22, 2026
…prefixed input (#17725)

## Summary

Media-type sniffing is documented as an O(1) check on the first ~18
bytes (24 base64 chars). An `ID3`/`SUQz`-prefixed attachment bypassed
that bound: `stripID3TagsIfPresent` ran before the truncation and
decoded the entire base64 attachment (plus a full-size `.slice()` copy),
then returned a `Uint8Array` so the 24-char cap was skipped. A
4-character prefix flipped intended O(1) sniffing into an O(N) decode +
copy of the whole attachment, on every path including image detection.

## Hardening

- Decode a bounded prefix first, then strip ID3 within that bound, so no
input can force a full decode.
- Detection now runs on decoded bytes (`ID3`) instead of the base64
string (`startsWith('SUQz')`), so string and raw-byte inputs are checked
uniformly and base64 alignment tricks cannot fool it.
- The raw-`Uint8Array` path is bounded too, and `stripID3` uses
`subarray` (a view) instead of `slice` (a full copy), removing the
second O(N) copy.
- ID3 skip is capped at `ID3_MAX_SCAN_BYTES` (128 KB), large enough to
cover typical tags with embedded cover art while keeping cost constant
in attachment size.

## Happy path

- Non-ID3 attachments (PNG, JPEG, WEBP, PDF, WAV, and so on) decode only
~18 bytes exactly as before, so common detection is unchanged.
- MP3s with an ID3v2 tag up to 128 KB, including typical embedded cover
art, still auto-detect as `audio/mpeg`.
- `data:` URLs, base64url, `ArrayBuffer`, `Buffer`, and `text` inputs
are normalized upstream and behave the same.

## Unhappy path

- An `ID3`/`SUQz`-prefixed attachment (benign or crafted) now decodes at
most ~128 KB regardless of attachment size, so sniffing stays
constant-time instead of scaling with the input.
- An ID3v2 tag larger than 128 KB is no longer scanned past, so such an
MP3 is not auto-detected and falls back to the caller-supplied media
type (`resolveFullMediaType` throws its existing "must specify subtype"
error if none was provided). Raise `ID3_MAX_SCAN_BYTES` if larger tags
must be detected; cost stays O(1) either way.
- A crafted oversized `id3Size` cannot read out of bounds: `subarray`
clamps to length and returns empty, so no signature matches and
detection returns `undefined`.

## Testing

- Added a regression test that an ID3 tag larger than the scan bound is
not detected (locks in the O(1) behavior).
- Full `detect-media-type` suite passes (67 tests).

Fixes #17709

## Related Issues

Backport of #17386

---------

Co-authored-by: Mukund Sarma <23266464+dnukumamras@users.noreply.github.com>
lgrammel added a commit that referenced this pull request Jul 22, 2026
…prefixed input (#17746)

## Summary

Media-type sniffing is documented as an O(1) check on the first ~18
bytes (24 base64 chars). An `ID3`/`SUQz`-prefixed attachment bypassed
that bound: `stripID3TagsIfPresent` ran before the truncation and
decoded the entire base64 attachment (plus a full-size `.slice()` copy),
then returned a `Uint8Array` so the 24-char cap was skipped. A
4-character prefix flipped intended O(1) sniffing into an O(N) decode +
copy of the whole attachment, on every path including image detection.

## Hardening

- Decode a bounded prefix first, then strip ID3 within that bound, so no
input can force a full decode.
- Detection now runs on decoded bytes (`ID3`) instead of the base64
string (`startsWith('SUQz')`), so string and raw-byte inputs are checked
uniformly and base64 alignment tricks cannot fool it.
- The raw-`Uint8Array` path is bounded too, and `stripID3` uses
`subarray` (a view) instead of `slice` (a full copy), removing the
second O(N) copy.
- ID3 skip is capped at `ID3_MAX_SCAN_BYTES` (128 KB), large enough to
cover typical tags with embedded cover art while keeping cost constant
in attachment size.

## Happy path

- Non-ID3 attachments (PNG, JPEG, WEBP, PDF, WAV, and so on) decode only
~18 bytes exactly as before, so common detection is unchanged.
- MP3s with an ID3v2 tag up to 128 KB, including typical embedded cover
art, still auto-detect as `audio/mpeg`.
- `data:` URLs, base64url, `ArrayBuffer`, `Buffer`, and `text` inputs
are normalized upstream and behave the same.

## Unhappy path

- An `ID3`/`SUQz`-prefixed attachment (benign or crafted) now decodes at
most ~128 KB regardless of attachment size, so sniffing stays
constant-time instead of scaling with the input.
- An ID3v2 tag larger than 128 KB is no longer scanned past, so such an
MP3 is not auto-detected and falls back to the caller-supplied media
type (`resolveFullMediaType` throws its existing "must specify subtype"
error if none was provided). Raise `ID3_MAX_SCAN_BYTES` if larger tags
must be detected; cost stays O(1) either way.
- A crafted oversized `id3Size` cannot read out of bounds: `subarray`
clamps to length and returns empty, so no signature matches and
detection returns `undefined`.

## Testing

- Added a regression test that an ID3 tag larger than the scan bound is
not detected (locks in the O(1) behavior).
- Full `detect-media-type` suite passes (67 tests).

Fixes #17709

## Related Issues

Backport of #17386

---------

Co-authored-by: Mukund Sarma <23266464+dnukumamras@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Media-type sniffing performs unbounded work for ID3-prefixed attachments

2 participants