fix(tokenizer): contain malformed input — clamp children to parent bounds, treat both delimiters as terminators (review #1-#7) - #15
Merged
Conversation
…elimiters as terminators Adversarial-review findings #1-#7 — a cluster of silent data-loss/corruption bugs on malformed input (the attack surface flagged by SECURITY.md), all sharing one root cause: child constructs were bounded by the whole stream rather than their enclosing item/sequence, and the delimiter-terminator set was incomplete. - #1/#3/#7: delimitation items are now structural terminators at element boundaries in any item frame — FFFE,E00D ends and is consumed; FFFE,E0DD ends the item without consuming (it belongs to the sequence). Previously a stray/mis-set delimiter was read as an element 'xfffee00d'/'xfffee0dd' and the item swallowed every following root element to EOF. - #4: scanUnknown is bounded by frame.bound; an undefined-length non-sequence element can no longer eat its siblings to end-of-stream when its delimiter is missing. - #5: sequence items are bounded by their enclosing sequence, not the stream; an overlong item length is clamped (warning) instead of pulling in siblings, removing the asymmetry with readValue/pushSequence recovery. - #2: the encapsulated basic offset table is bounded by the value end, not the stream, so a defined-length overrun no longer reads the next element's bytes as offset entries (falls back to opaque via the speculative frame). - #6: defined-length encapsulated pixel data always resumes exactly at the value end, so trailing padding can't surface as phantom elements. Six regression tests added; full suite (625) green including the byte-identical corpus round-trip (no offset-model regressions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DSSLWZvjSRByQP3KsghQcJ
There was a problem hiding this comment.
Pull request overview
This PR hardens the tokenizer against malformed DICOM input by enforcing parent-frame bounds during scanning/iteration and by treating both item/sequence delimitation items as structural terminators in non-root item frames, preventing silent data loss/corruption on adversarial structures.
Changes:
- Add structural termination for item frames when encountering delimitation items at element boundaries.
- Bound undefined-length “unknown” scans and sequence item extents to their enclosing frame to prevent sibling swallowing.
- Tighten encapsulated pixel data scanning by bounding BOT reads to the value end and resuming exactly at the defined-length value end; add regression tests for review cases #1–#7.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/tokenizer.ts | Adds delimiter-based termination for item frames; clamps item bounds to sequence; bounds scanUnknown to frame bounds. |
| src/tokenizer.test.ts | Adds regression tests covering multiple malformed-input containment scenarios (#1–#7). |
| src/encapsulated.ts | Bounds BOT reads to the value end for defined-length encapsulation; forces resume at value end to avoid phantom trailing elements. |
Comments suppressed due to low confidence (3)
src/tokenizer.ts:237
- For undefined-length item frames, this branch uses
stream.remainingand seeks to end-of-stream when the delimiter is missing. When the item is nested inside a defined-length sequence, that can run pastframe.boundand either (a) swallow sibling elements or (b) throwmalformedonce an element overruns the bound, instead of containing the scan to the enclosing sequence/item bound.
This issue also appears in the following locations of the same file:
- line 252
- line 427
// Delimitation items are structural terminators at element boundaries in
// any non-root (item) frame — a real data element never has group FFFE,
// so this cannot misfire on genuine values (review #1/#3/#7).
if (!frame.root && this.terminateItemAtDelimiter(frame)) {
return;
}
if (frame.undefinedLength) {
if (this.stream.remaining < 8) {
this.warn('missing-item-delimiter', 'eof encountered before finding item delimiter (FFFE,E00D) in item of undefined length');
this.stream.seek(this.stream.remaining);
this.finalizeDataSet(frame, this.stream.position, this.stream.position);
return;
}
this.readElement(frame);
return;
src/tokenizer.ts:444
scanUnknownis intended to be bounded byframe.bound, but the loop currently checksthis.stream.remaining < 8(end-of-stream) rather than bytes remaining withinframe.bound. Ifframe.bound < stream.length, this can peek/seek past the enclosing frame and potentially consume the next sibling's delimiter/tag bytes while scanning.
private scanUnknown(frame: DataSetFrame, header: ElementHeader): UnknownElement {
const maxEnd = frame.bound;
let contentEnd: number;
for (;;) {
if (this.stream.position >= maxEnd || this.stream.remaining < 8) {
this.warn('missing-item-delimiter', `element ${tagToString(header.tag)} of undefined length has no delimitation item; using end of data`);
this.stream.seek(maxEnd - this.stream.position);
contentEnd = this.stream.position;
break;
}
const peeked = this.stream.peekTag();
if (peeked === TAG_ITEM_DELIMITATION || peeked === TAG_SEQUENCE_DELIMITATION) {
contentEnd = this.stream.position;
this.consumeDelimiter(`delimiter of ${tagToString(header.tag)}`);
break;
}
this.stream.seek(2);
}
src/tokenizer.ts:259
terminateItemAtDelimiterconsumes an 8-byte delimiter based onstream.remaining >= 8, but does not ensure those 8 bytes are withinframe.bound. Ifframe.boundis smaller thanstream.length(nested items), this can consume past the enclosing frame boundary while trying to terminate the item.
private terminateItemAtDelimiter(frame: DataSetFrame): boolean {
const peeked = this.stream.peekTag();
if (peeked === TAG_ITEM_DELIMITATION && this.stream.remaining >= 8) {
const delimiterStart = this.stream.position;
this.consumeDelimiter('item delimiter');
this.finalizeDataSet(frame, delimiterStart, this.stream.position);
return true;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second batch from the deep adversarial review: a cluster of silent data-loss/corruption bugs on malformed input — precisely the attack surface SECURITY.md flags. Each was independently reproduced (repros in the review report). All share one root cause: child constructs were bounded by the whole stream rather than their enclosing item/sequence, and the delimiter-terminator set was incomplete.
FFFE,E00D/E0DDwas read as an elementxfffee00d; the item then swallowed every following root element to EOFscanUnknownis bounded byframe.boundreadValue/pushSequencewhich threw)6 regression tests added. Full suite (620) green including the byte-identical corpus round-trip — no offset-model regressions.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DSSLWZvjSRByQP3KsghQcJ