Skip to content

connectors: byte downloads are unbounded — cap file size before download and parse #164

Description

@qiuethan

services/connectors downloads and fully parses a caller-supplied file before any size limit is applied. Surfaced by the final review of #163 (PDF/.docx extractors).

What's actually unbounded

PdfExtractor and DocxExtractor each pull the entire file into memory via files().get(fileId=..., alt="media")io.BytesIO(payload), parse it in full, and only then return text to google.py, where max_content_chars (default 1,200,000) truncates.

The cap is applied after the cost has already been paid. Its own comment describes it as a transport guard that "stops a pathological file becoming a huge HTTP response" — it was never a guard on input size.

Two things that look like they'd catch this and don't:

  • request_timeout_s = 30 is an httplib2 socket timeout, not a request deadline. A large file that streams steadily never trips it.
  • Nothing bounds pypdf's parse time. It's pure Python; extract_text() across a few hundred pages pins a FastAPI threadpool worker for minutes while holding the byte buffer and the extracted text.

This also predates the PDF work: the text/* media path has the same shape.

Why it's inconsistent with the rest of the package

SheetsExtractor sets an explicit MAX_ROWS_PER_TAB = 2000 and warns when it truncates. PDF and .docx got neither a cap nor a warning, and the connectors README's "Known limitations" lists only the spreadsheet cap and the scanned-PDF case — an operator reading it would conclude there is no other size exposure.

The fix is one place, not three

google.py already calls files().get(fileId=..., fields="name,mimeType") for metadata before dispatching to an extractor. Adding size to that fields list and rejecting oversized files with SourceUnsupported guards PDF, .docx, and the pre-existing text/* path from a single point — no per-extractor changes, no duplication of the check across the three call sites that currently repeat the media download (drive_export.py, pdf.py, docx.py).

Note size is absent for native Google types (Docs/Sheets/Slides/Forms), which are fetched through their own APIs rather than downloaded — the guard should skip rather than reject when the field is missing.

Open decisions

  • What limit? Needs a number. Resumes are a few hundred KB; the exposure is a caller pointing at something pathological.
  • Reject or truncate? Rejecting with a clear 422 is honest; silently truncating a 500-page PDF reproduces the scanned-PDF failure mode of looking successfully ingested.
  • Worth a MAX_FILE_BYTES in config.py alongside max_content_chars, and a README line so the limitation is documented rather than discovered.

Why it was deferred out of #163

Choosing the limit is a design call, and the text/* exposure isn't a regression from that branch. Not a today-outage — the shared folder holds ordinary resumes — but it's an unbounded blast radius on a service that accepts a caller-supplied URL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/integrationThird-party integrations (Drive, Notion, webhooks)area/serviceBackend API services (generic/cross-cutting)type/bugSomething is broken

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions