Skip to content
This repository was archived by the owner on Jun 9, 2026. It is now read-only.

Add file size validation to OCR extractors and attachment upload - #1724

Open
selta-goodparty wants to merge 11 commits into
developfrom
claude/autopatch-scan-9aef26fc-vuln-1306931-lhwal
Open

Add file size validation to OCR extractors and attachment upload#1724
selta-goodparty wants to merge 11 commits into
developfrom
claude/autopatch-scan-9aef26fc-vuln-1306931-lhwal

Conversation

@selta-goodparty

@selta-goodparty selta-goodparty commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds proactive file size validation to prevent decompression bombs and oversized uploads. The DOCX extractor now parses ZIP central directory headers to detect decompressed size before extraction, while both DOCX and PDF extractors enforce a 20 MB buffer limit. The attachment service validates actual S3 object size against declared size during upload completion.

Key Changes

  • DOCX extractor (src/ocr/extractors/docx.extractor.ts):

    • Added findEocdOffset() and totalUncompressedSize() helpers to parse ZIP central directory and calculate total uncompressed size without decompressing
    • Enforces MAX_BUFFER_BYTES (20 MB) and MAX_DECOMPRESSED_BYTES (100 MB) limits before calling mammoth
    • Throws BadRequestException with specific error codes for invalid archives or size violations
  • PDF extractor (src/ocr/extractors/pdf.extractor.ts):

    • Added MAX_BUFFER_BYTES (20 MB) validation before PDF parsing
    • Throws BadRequestException('attachment_too_large') if buffer exceeds limit
  • Attachment service (src/annotations/services/annotationAttachmentService.ts):

    • Added ATTACHMENT_MAX_BYTES constant (20 MB)
    • Changed completeUpload() to call new s3.getObjectSize() instead of objectExists()
    • Validates actual S3 object size against both the 20 MB limit and the declared sizeBytes
    • Updated JSDoc to reflect new validation behavior
  • S3 service (src/vendors/aws/services/s3.service.ts):

    • Added getObjectSize() method that returns ContentLength from HeadObjectCommand
    • Returns undefined if object doesn't exist (404), allowing caller to distinguish missing vs. oversized

Implementation Details

The DOCX validation uses ZIP format knowledge to avoid decompressing the entire file—it reads the End of Central Directory record and iterates central directory entries to sum uncompressed sizes. This allows early rejection of zip bombs before mammoth processes them.

All three validation points (declared size, buffer size, decompressed size) work together: the service gates upload completion, then extractors provide defense-in-depth before calling third-party libraries.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr


Note

Medium Risk
Changes attachment upload gating and OCR input handling (resource exhaustion / zip bombs); logic is localized but mistakes could block legitimate uploads or leave presign without declared-size caps.

Overview
Tightens annotation attachment handling so uploads and OCR cannot pull unbounded bytes into the API or third-party parsers.

Upload completion now uses S3 HeadObject ContentLength (via new getObjectSize) instead of a mere existence check. It rejects missing objects, objects over 20 MB, and objects larger than the declared sizeBytes (upload_size_exceeded).

PDF and DOCX OCR paths enforce a 20 MB in-memory cap before parsing. DOCX additionally walks the ZIP central directory (without decompressing) and blocks archives whose summed uncompressed size exceeds 100 MB, mitigating zip-bomb style abuse before mammoth runs.

Reviewed by Cursor Bugbot for commit 05c829f. Configure here.

Enforces actual S3 object size against declared size in completeUpload
and adds ZIP central directory inspection in the DOCX extractor to reject
archives whose total uncompressed size exceeds 100 MB before mammoth/jszip
decompresses them. Also adds buffer size guards to both DOCX and PDF
extractors as defense in depth.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

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

2 blocker(s). Reply delegate review after fixing.

Comment thread src/vendors/aws/services/s3.service.ts
Comment thread src/ocr/extractors/docx.extractor.ts
claude added 2 commits June 2, 2026 03:41
Adds 4 tests for S3Service.getObjectSize (success, NoSuchKey, HTTP 404,
rethrow) and 5 tests for DocxOcrExtractor covering buffer size limit,
invalid archive detection, decompression size limit, missing object, and
valid passthrough.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
The integration test mocked the old objectExists call; now mocks
getObjectSize to match the updated completeUpload flow that verifies
actual S3 object size.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔴 Lines 65.12% 8347 / 12817
🔴 Statements 55.91% 10046 / 17967
🔴 Functions 52.93% 2030 / 3835
🔴 Branches 46.42% 5420 / 11675
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/annotations/services/annotationAttachment.service.ts 87.65% 80% 100% 89.87% 59, 87, 89, 98, 196, 199, 257, 298-300, 322-326
src/ocr/extractors/docx.extractor.ts 98.21% 90.62% 100% 98.11% 46
src/ocr/extractors/pdf.extractor.ts 100% 75% 100% 100%
Generated in workflow #3822 for commit 4e77aa4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

59.90% (average of lines, statements, functions, and branches)

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

@goodparty_org/contracts RC published: 0.0.0-rc-pr-1724.50267d81f24df95f4f950c0dd560c83bc665ae79

npm install @goodparty_org/contracts@0.0.0-rc-pr-1724.50267d81f24df95f4f950c0dd560c83bc665ae79

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Preview Environment

Your preview environment is ready!

Resource URL
API https://pr-1724.preview.goodparty.org
Health Check https://pr-1724.preview.goodparty.org/v1/health

This environment will be automatically destroyed when the PR is closed.

@selta-goodparty

Copy link
Copy Markdown
Contributor Author

delegate review

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

2 resolved since last review, 2 new.

2 blocker(s). Reply delegate review after fixing.

Comment on lines +27 to +29
if (bytes.length > MAX_BUFFER_BYTES) {
throw new BadRequestException('attachment_too_large')
}

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 MAX_BUFFER_BYTES guard and the missing-object path in PdfOcrExtractor.extract have no unit test coverage. docx.extractor.test.ts establishes the repo pattern: each extractor gets a direct-instantiation test file covering the not-found, oversized-buffer, and happy-path cases. ocr.service.test.ts mocks this extractor entirely, so these branches are never exercised and could regress silently.\n\nAdd src/ocr/tests/pdf.extractor.test.ts with at minimum: (1) s3.getFileBytes returns undefinedNotFoundException; (2) buffer length exceeds MAX_BUFFER_BYTESBadRequestException('attachment_too_large'); (3) valid small buffer → calls pdf-parse and returns the text. Mock pdf-parse with vi.mock as mammoth is mocked in the docx test.\n\n

.spyOn(s3, 'getSignedUrlForViewing')
.mockResolvedValue('https://s3.example/download-url'),
exists: vi.spyOn(s3, 'objectExists').mockResolvedValue(true),
exists: vi.spyOn(s3, 'getObjectSize').mockResolvedValue(1_500_000),

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 completeUpload tests have no case for the second branch of the size guard (actualSize > attachment.sizeBytes when actualSize is still under ATTACHMENT_MAX_BYTES). This mock returns 1_500_000 while size_bytes is also 1_500_000 — equal values, so the declared-vs-actual comparison is never proven to fire.\n\nAdd a test that mocks getObjectSize to return a value larger than the presigned size_bytes but below 20 MB, and asserts a 400 with upload_size_exceeded.\n\n

Adds pdf.extractor.test.ts covering missing object, oversized buffer,
and valid extraction paths. Adds a completeUpload test asserting 400
when the actual S3 object size exceeds the declared size_bytes.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

61.25% (average of lines, statements, functions, and branches)

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@goodparty_org/contracts RC published: 0.0.0-rc-pr-1724.133bc6b34da40252df6d88417a467768315e5cbc

npm install @goodparty_org/contracts@0.0.0-rc-pr-1724.133bc6b34da40252df6d88417a467768315e5cbc

@selta-goodparty

Copy link
Copy Markdown
Contributor Author

delegate review

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

0 resolved since last review, 1 new.
1 blocker(s). Reply delegate review after fixing.

Comment thread src/ocr/extractors/docx.extractor.ts
ZIP64 archives use 0xFFFFFFFF sentinels in the 32-bit EOCD fields.
The decompression size check now returns 0 (skips) when ZIP64 sentinels
are detected in the CD offset/size, and skips individual entries with
sentinel uncompressed sizes. The 20 MB buffer limit still protects
against oversized payloads.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

61.22% (average of lines, statements, functions, and branches)

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@goodparty_org/contracts RC published: 0.0.0-rc-pr-1724.3aac794b5f4fbf6c0184a653b4b31b1170d31ec6

npm install @goodparty_org/contracts@0.0.0-rc-pr-1724.3aac794b5f4fbf6c0184a653b4b31b1170d31ec6

@selta-goodparty

Copy link
Copy Markdown
Contributor Author

delegate review

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

0 resolved since last review, 2 new.
2 blocker(s). Reply delegate review after fixing.

Comment thread src/ocr/extractors/docx.extractor.ts Outdated
Comment thread src/annotations/services/annotationAttachment.service.ts
claude added 3 commits June 5, 2026 03:54
ZIP64 archives bypass the decompression size check since their 32-bit
EOCD fields are sentinels. DOCX files never legitimately need ZIP64, so
reject them outright instead of skipping the guard.

Also adds throwOnError: true to the sendMessage call in completeUpload
so a queue failure surfaces as a 502 instead of silently dropping the
OCR job.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
Asserts completeUpload passes throwOnError:true to the OCR producer and
adds a case proving a producer failure surfaces (no silent ack). Drops
an unnecessary `as never` cast and restores the DocxOcrExtractor doc
comment removed earlier.

https://claude.ai/code/session_01TSQLE4BhPu4sBT1zUupAnr
develop added a unique constraint on elected_office.user_id. Both the
annotation download ownership test and the polls cross-office test
created a second elected office for the same user within one test, which
now violates the constraint on the PR merge ref. Assign the foreign
office to a separate user, mirroring the passing presign access test.
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

62.18% (average of lines, statements, functions, and branches)

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@goodparty_org/contracts RC published: 0.0.0-rc-pr-1724.d561081f1440917e907c8123c9c8ce06d2ddd9d7

npm install @goodparty_org/contracts@0.0.0-rc-pr-1724.d561081f1440917e907c8123c9c8ce06d2ddd9d7

@selta-goodparty

Copy link
Copy Markdown
Contributor Author

delegate review

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

0 resolved since last review, 1 new.
1 blocker(s). Reply delegate review after fixing.

Comment thread src/ocr/extractors/docx.extractor.ts Outdated
Comment on lines +56 to +59
const nameLen = buf.readUInt16LE(pos + 28)
const extraLen = buf.readUInt16LE(pos + 30)
const commentLen = buf.readUInt16LE(pos + 32)
pos += 46 + nameLen + extraLen + commentLen

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.

A crafted CD entry with an inflated commentLen (or nameLen/extraLen) can advance pos past cdEnd, causing the while-loop to exit before reading subsequent entries. If the large-uncompressed-size entry is placed last, its size is never added to total, and the ZIP-bomb guard silently passes. The attacker writes a multi-entry ZIP where entries 0..N-2 have small uncompressed sizes but entry N-1 has commentLen=65535, which jumps pos past cdEnd before entry N (with uncompressed=~4GB) is ever read. totalUncompressedSize returns a small value and mammoth decompresses the bomb.

No downstream guard catches this — the only decompression-size check is the comparison against MAX_DECOMPRESSED_BYTES after this function returns.

Suggested change
const nameLen = buf.readUInt16LE(pos + 28)
const extraLen = buf.readUInt16LE(pos + 30)
const commentLen = buf.readUInt16LE(pos + 32)
pos += 46 + nameLen + extraLen + commentLen
const nameLen = buf.readUInt16LE(pos + 28)
const extraLen = buf.readUInt16LE(pos + 30)
const commentLen = buf.readUInt16LE(pos + 32)
const next = pos + 46 + nameLen + extraLen + commentLen
if (next > cdEnd) {
throw new BadRequestException('attachment_invalid_archive')
}
pos = next

claude added 2 commits June 6, 2026 08:03
A malformed CD entry whose name/extra/comment lengths sum past the CD
boundary could cause the iterator to skip the final entries, letting a
trailing zip-bomb entry evade the decompression-size check. Validate
that the next entry offset stays within cdEnd before advancing.
…can-9aef26fc-vuln-1306931-lhwal

# Conflicts:
#	src/annotations/tests/annotationAttachments.controller.test.ts
#	src/vendors/aws/services/s3.service.ts
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

55.07% (average of lines, statements, functions, and branches)

@selta-goodparty

Copy link
Copy Markdown
Contributor Author

delegate review

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

Advisory mode — this PR has had 5+ prior non-approval bot review rounds. Further blocking comments would be churn rather than signal. 4 concern(s) remain below for human reviewers; the bot will not block this PR again. Push more commits to retrigger the bot on a fresh head if needed.


src/ocr/extractors/docx.extractor.ts:27 — EOCD scan accepts false-positive signature from ZIP comment

The backward scan returns the first (highest-offset) EOCD signature match, but a ZIP comment that contains the four bytes 0x06054b50 will appear after the true EOCD in the file and be found first. When that false EOCD's cdOffset/cdSize point at a region with no valid CD entries, totalUncompressedSize returns 0. A 0-byte total passes the zip-bomb guard unconditionally, so a crafted DOCX can carry an arbitrarily large decompressed payload and bypass the check entirely before being handed to mammoth.

The ZIP spec disambiguates by requiring that the EOCD comment-length field (buf.readUInt16LE(i + 20)) equals buf.length - i - 22. Add that validation to the scan so only the structurally correct EOCD is accepted:

const findEocdOffset = (buf: Buffer): number => {
  for (
    let i = buf.length - EOCD_MIN_SIZE;
    i >= Math.max(0, buf.length - 65557);
    i--
  ) {
    if (
      buf.readUInt32LE(i) === EOCD_SIGNATURE &&
      buf.readUInt16LE(i + 20) === buf.length - i - 22
    )
      return i
  }
  return -1
}

src/ocr/extractors/docx.extractor.ts:48 — Silent break on unexpected CD signature allows zip-bomb bypass

When buf.readUInt32LE(pos) !== CD_ENTRY_SIGNATURE the loop breaks silently, returning only the partial accumulated total. An attacker can craft a DOCX where the first few central-directory entries sum to just under 100 MB (passing the check), then embed a non-signature byte mid-CD so the loop exits early — while the actual local file entries that mammoth decompresses can be arbitrarily large. The MAX_DECOMPRESSED_BYTES guard is entirely defeated.

A non-matching signature mid-CD is unambiguous corruption; it should be rejected:

    if (buf.readUInt32LE(pos) !== CD_ENTRY_SIGNATURE)
      throw new BadRequestException('attachment_invalid_archive')

src/annotations/services/annotationAttachment.service.ts:203 — Zero-byte upload bypasses upload_not_received guard

S3 returns ContentLength: 0 for an empty object; head.contentLength === null is false for 0, so the check passes. The subsequent size comparisons (0 > ATTACHMENT_MAX_BYTES and 0 > attachment.sizeBytes) are also both false (since sizeBytes is at least 1 per the contract's .positive() constraint), so the OCR job is enqueued for a zero-byte file.

Fix: if (!head || !head.contentLength) {

src/ocr/tests/docx.extractor.test.ts:190-197 — Tautological ZIP64 test

buildZip64EocdBuffer sets 0xffffffff in both eocd+12 (cdSize) and eocd+16 (cdOffset), so totalUncompressedSize throws at the EOCD-level sentinel check and never enters the CD-entry loop. The uncompressed === ZIP64_SENTINEL branch has zero test coverage.

buildZipBuffer(0xffffffff) already builds a ZIP with valid EOCD fields and a CD entry whose uncompressed size is 0xffffffff, which exercises the intended code path. Replace the test fixture:

  it('rejects ZIP64 archives as unsupported', async () => {
    const zip64 = buildZipBuffer(0xffffffff)
    const { extractor } = buildExtractor(zip64)

    await expect(extractor.extract(input())).rejects.toThrow(
      'attachment_unsupported_format',
    )
  })

- findEocdOffset now validates the comment-length field so a signature
  inside an archive comment cannot be mistaken for the real EOCD (which
  would zero out the size walk and bypass the bomb guard).
- A bad central-directory entry signature mid-walk now throws instead of
  silently stopping, closing an early-termination bypass.
- completeUpload rejects zero-byte objects (ContentLength 0 previously
  slipped past the null check).
- Add tests for the comment false-positive, corrupt CD entry, and the
  per-entry ZIP64 size sentinel branch.
@selta-goodparty
selta-goodparty enabled auto-merge June 8, 2026 18:39
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

📊 Overall Test Coverage

55.10% (average of lines, statements, functions, and branches)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants