From fd70a0d6d327ef4c207610c085ee642da5fdfe2d Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=93=91=20feat:=20Add=20Standalone=20F?= =?UTF-8?q?ile=20Extraction=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/extraction.yml | 25 ++ README.md | 4 + packages/extraction/.gitattributes | 2 + packages/extraction/.gitignore | 1 + packages/extraction/Dockerfile | 32 +++ packages/extraction/README.md | 169 ++++++++++++ packages/extraction/compose.yaml | 22 ++ packages/extraction/native/guard.c | 141 ++++++++++ packages/extraction/package-lock.json | 64 +++++ packages/extraction/package.json | 22 ++ packages/extraction/src/backend.ts | 165 ++++++++++++ packages/extraction/src/boundary.test.ts | 22 ++ packages/extraction/src/health.ts | 15 ++ packages/extraction/src/main.ts | 64 +++++ packages/extraction/src/protocol.test.ts | 68 +++++ packages/extraction/src/protocol.ts | 106 ++++++++ packages/extraction/src/server.test.ts | 249 +++++++++++++++++ packages/extraction/src/server.ts | 255 ++++++++++++++++++ packages/extraction/tests/adversarial.py | 44 +++ packages/extraction/tests/block-landlock.c | 21 ++ .../extraction/tests/container-client.mjs | 122 +++++++++ packages/extraction/tests/container.sh | 59 ++++ .../extraction/tests/fixtures/aggregate.pdf | Bin 0 -> 37605 bytes packages/extraction/tests/fixtures/body.docx | Bin 0 -> 36732 bytes .../extraction/tests/fixtures/compressed.pdf | Bin 0 -> 707 bytes packages/extraction/tests/fixtures/empty.docx | Bin 0 -> 36563 bytes packages/extraction/tests/fixtures/empty.pdf | Bin 0 -> 660 bytes .../extraction/tests/fixtures/generate.py | 37 +++ .../extraction/tests/fixtures/inflate.pdf | Bin 0 -> 8852 bytes packages/extraction/tests/fixtures/pages.pdf | Bin 0 -> 950 bytes packages/extraction/tests/isolation.mjs | 64 +++++ packages/extraction/tests/isolation.py | 36 +++ packages/extraction/tsconfig.json | 15 ++ packages/extraction/worker/extract.py | 217 +++++++++++++++ packages/extraction/worker/requirements.txt | 4 + 35 files changed, 2045 insertions(+) create mode 100644 .github/workflows/extraction.yml create mode 100644 packages/extraction/.gitattributes create mode 100644 packages/extraction/.gitignore create mode 100644 packages/extraction/Dockerfile create mode 100644 packages/extraction/README.md create mode 100644 packages/extraction/compose.yaml create mode 100644 packages/extraction/native/guard.c create mode 100644 packages/extraction/package-lock.json create mode 100644 packages/extraction/package.json create mode 100644 packages/extraction/src/backend.ts create mode 100644 packages/extraction/src/boundary.test.ts create mode 100644 packages/extraction/src/health.ts create mode 100644 packages/extraction/src/main.ts create mode 100644 packages/extraction/src/protocol.test.ts create mode 100644 packages/extraction/src/protocol.ts create mode 100644 packages/extraction/src/server.test.ts create mode 100644 packages/extraction/src/server.ts create mode 100644 packages/extraction/tests/adversarial.py create mode 100644 packages/extraction/tests/block-landlock.c create mode 100644 packages/extraction/tests/container-client.mjs create mode 100755 packages/extraction/tests/container.sh create mode 100644 packages/extraction/tests/fixtures/aggregate.pdf create mode 100644 packages/extraction/tests/fixtures/body.docx create mode 100644 packages/extraction/tests/fixtures/compressed.pdf create mode 100644 packages/extraction/tests/fixtures/empty.docx create mode 100644 packages/extraction/tests/fixtures/empty.pdf create mode 100644 packages/extraction/tests/fixtures/generate.py create mode 100644 packages/extraction/tests/fixtures/inflate.pdf create mode 100644 packages/extraction/tests/fixtures/pages.pdf create mode 100644 packages/extraction/tests/isolation.mjs create mode 100644 packages/extraction/tests/isolation.py create mode 100644 packages/extraction/tsconfig.json create mode 100644 packages/extraction/worker/extract.py create mode 100644 packages/extraction/worker/requirements.txt diff --git a/.github/workflows/extraction.yml b/.github/workflows/extraction.yml new file mode 100644 index 00000000..29a55217 --- /dev/null +++ b/.github/workflows/extraction.yml @@ -0,0 +1,25 @@ +name: Extraction Service +on: + pull_request: + paths: ['packages/extraction/**', '.github/workflows/extraction.yml'] + push: + branches: [main] + paths: ['packages/extraction/**', '.github/workflows/extraction.yml'] +permissions: + contents: read +jobs: + extraction: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.16.0 + cache: npm + cache-dependency-path: packages/extraction/package-lock.json + - run: npm ci --ignore-scripts + working-directory: packages/extraction + - run: npm run format:check && npm run typecheck && npm test + working-directory: packages/extraction + - run: npm run test:container + working-directory: packages/extraction diff --git a/README.md b/README.md index b746e3c0..2c99051b 100644 --- a/README.md +++ b/README.md @@ -191,3 +191,7 @@ session-management routes stay unauthenticated for backwards compatibility. - Worker: `GET /health` and `GET /ready` - File Server: `GET /health` and `GET /ready` - Tool Call Server: `GET /health` + +## Standalone File Extraction + +[File extraction service](packages/extraction/README.md): a separate, self-hostable companion container for bounded PDF/DOCX text extraction. It does not require or modify the Code Interpreter API or `/exec`. diff --git a/packages/extraction/.gitattributes b/packages/extraction/.gitattributes new file mode 100644 index 00000000..67da8786 --- /dev/null +++ b/packages/extraction/.gitattributes @@ -0,0 +1,2 @@ +tests/fixtures/*.pdf binary +tests/fixtures/*.docx binary diff --git a/packages/extraction/.gitignore b/packages/extraction/.gitignore new file mode 100644 index 00000000..2e3a563c --- /dev/null +++ b/packages/extraction/.gitignore @@ -0,0 +1 @@ +native/guard diff --git a/packages/extraction/Dockerfile b/packages/extraction/Dockerfile new file mode 100644 index 00000000..4acf5d9c --- /dev/null +++ b/packages/extraction/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24.16.0-bookworm-slim AS build +WORKDIR /build +COPY packages/extraction/package*.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY packages/extraction/tsconfig.json ./ +COPY packages/extraction/src ./src +RUN npm run build +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev linux-libc-dev && rm -rf /var/lib/apt/lists/* +COPY packages/extraction/native/guard.c ./guard.c +RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c +COPY packages/extraction/tests/block-landlock.c ./block-landlock.c +RUN gcc -O2 -Wall -Wextra -Werror -o block-landlock block-landlock.c + +FROM python:3.13-slim-bookworm@sha256:5024f48ba9441d4b13a95d3945abc6365538e3a31109833367a1923523c6efed AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends libstdc++6 libatomic1 && rm -rf /var/lib/apt/lists/* +COPY packages/extraction/worker/requirements.txt /opt/extraction/requirements.txt +RUN pip install --no-cache-dir -r /opt/extraction/requirements.txt +COPY --from=build /usr/local/bin/node /usr/local/bin/node +COPY --from=build --chmod=0555 /build/guard /opt/extraction/guard +COPY --from=build --chmod=0555 /build/dist /opt/extraction/dist +COPY --chmod=0444 packages/extraction/package.json /opt/extraction/package.json +COPY --chmod=0444 packages/extraction/worker/extract.py /opt/extraction/extract.py +RUN groupadd -g 10001 extraction && useradd -u 10001 -g extraction -M extraction && \ + mkdir /socket /jobs && chown extraction:extraction /socket /jobs && chmod 0700 /jobs && chmod 0770 /socket +USER 10001:10001 +WORKDIR /opt/extraction +HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["node", "dist/health.js"] +CMD ["node", "dist/main.js"] + +FROM runtime AS qualification +COPY --chown=10001:10001 packages/extraction/tests /tests +COPY --from=build --chmod=0555 /build/block-landlock /tests/block-landlock diff --git a/packages/extraction/README.md b/packages/extraction/README.md new file mode 100644 index 00000000..3ce21769 --- /dev/null +++ b/packages/extraction/README.md @@ -0,0 +1,169 @@ +# File extraction service + +Standalone companion container for fixed `document.extract-text` operations. It does not +start or call the Code Interpreter API, `/exec`, Redis, tool calling, or conversation storage. +LibreChat and RAG API client migrations are not included. + +## Deploy + +Requires a Linux Docker host with Landlock ABI 3 or newer (normally Linux 6.2+), seccomp, +and Docker Compose. Docker Desktop support depends on its Linux VM kernel and is not assumed. + +```sh +docker compose -f packages/extraction/compose.yaml up --build -d --wait +``` + +Only a Unix socket is exposed. Share the `extraction-socket` named volume with the authorized +caller, mounted read-only at its chosen socket directory. The caller must be able to use group +10001; the socket is mode 0660, its directory 0770. Do not publish a TCP port or mount the +Docker socket, application storage, secrets, or cloud/database credentials into this service. +Socket access authorizes use of the processor, not access to any user's files. + +The processor starts only after the restricted worker imports its dependencies and verifies +filesystem, socket, and process restrictions. Unsupported isolation or missing dependencies +prevent startup. A cleanup failure makes subsequent admission and readiness fail until restart. +No unsandboxed or local-parser fallback exists. + +## Protocol + +`GET /v1/capabilities` reports the protocol version, fixed operation, supported formats and caps. + +`POST /v1/extract-text` accepts document bytes directly, with these required headers: + +```text +Content-Type: application/octet-stream +Content-Length: +X-Extraction-Version: 1 +X-Extraction-Format: pdf | docx +``` + +Unknown extraction parameters, query parameters, chunked uploads, duplicate control headers, +and unsupported formats are rejected before parser execution. No scripts, paths, URLs, +passwords, parser options, or runtime selection are accepted. + +Example from a caller with the socket mounted at `/run/extraction`: + +```sh +curl --unix-socket /run/extraction/extraction.sock \ + -H 'Content-Type: application/octet-stream' \ + -H 'X-Extraction-Version: 1' -H 'X-Extraction-Format: pdf' \ + --data-binary @document.pdf http://localhost/v1/extract-text +``` + +The response is bounded UTF-8 JSON, directly returned without base64 or upload/download staging: + +```json +{ + "version": 1, + "operation": "document.extract-text", + "format": "pdf", + "inputSha256": "", + "textBytes": 31, + "segments": [ + { "kind": "page", "index": 1, "text": "First page text" }, + { "kind": "page", "index": 2, "text": "Second page text" } + ] +} +``` + +The supervisor validates version, operation, format, digest, segment kinds/order, exact UTF-8 +text byte count, and result size after the parser closes. Scratch cleanup completes before +success is sent. Admission remains held until response delivery finishes or the socket closes. + +Errors are `{ "error": { "code": "..." } }`, never raw parser diagnostics or submitted content. +HTTP 400 covers invalid requests; 413 covers input/decompression/structure/output caps; +422 covers malformed, encrypted, empty, unsupported-encoding, or resource-limited documents; +429 is immediate admission rejection; 503 is unavailable isolation/backend; 504 is a processing +deadline. A disconnected or stalled upload may close without a response. There is no queue. + +## Initial formats + +- **PDF:** pypdf, one segment per page, including empty pages. Text only, without OCR, + image decoding, attachment extraction, or script execution. Encrypted files are rejected. + Compressed text streams support a single Flate, ASCIIHex, or ASCII85 filter. Other filter + combinations are deliberately rejected. Malformed PDFs are not repaired. +- **DOCX:** python-docx/lxml, one document-body segment. Paragraphs and table rows remain + ordered; cells are tab-separated. Headers, footers, comments, text boxes, tracked revisions, + embedded files, and pagination are not extracted. ZIP entries are measured while reading, + then reconstructed into an uncompressed archive before the native XML parser sees them. + Duplicate names, unsafe paths, symlinks, encryption, non-UTF-8 XML and DTDs are rejected. + +Spreadsheet formats are not advertised. A later spreadsheet slice must return ordered sheet +segments, not flatten them into one string. Consumers retain page/sheet metadata and choose +any flattening themselves. Raw text/Markdown semantics, OCR and office previews are separate +operations, not fallback behavior of this endpoint. + +## Limits and lifecycle + +| Bound | Default | +| ------------------------------------------------ | --------------------: | +| Input | 10 MiB | +| Aggregate decoded PDF/ZIP content | 32 MiB | +| Individual decoded stream/ZIP entry | 8 MiB | +| ZIP entries | 512 | +| PDF pages | 128 | +| Extracted UTF-8 text | 1 MiB | +| Serialized result | 3 MiB | +| Admitted requests, including upload/delivery | 2 | +| HTTP connections | 16 | +| Request wall deadline, including upload/delivery | 10 seconds | +| Worker CPU time / address space | 8 seconds / 512 MiB | +| Worker file size / open descriptors | 4 MiB / 64 | +| Container memory / CPU / PIDs | 768 MiB / 2 CPUs / 32 | +| Shared scratch tmpfs | 96 MiB / 1,024 inodes | + +Bounds are fixed in this initial protocol and advertised by capabilities. The caller must bound +its own transport and apply stricter application-specific limits where needed. Cancellation +kills the parser process group and waits for process close before removing scratch. Container +shutdown closes connections, cancelling active work. Scratch tmpfs disappears with the container. + +## Isolation guarantees and limitations + +The shipped container is unprivileged, non-root, read-only, capability-free, without a network, +and uses Docker's default seccomp profile plus `no-new-privileges`. The Node 24 supervisor loads +no document parser. Every request starts a fresh Python subprocess through a small C launcher: + +- Landlock limits file reads to packaged runtime libraries, the worker, and its own job directory; + writes are limited to that job directory and `/dev/null`. +- An additional seccomp filter denies sockets, process creation, signals to other processes, + ptrace/process-memory access, namespace/mount operations, shared-memory IPC and selected + privileged syscalls. Only Python and its ELF loader receive Landlock execute access. +- The worker inherits no caller environment or descriptors other than standard I/O pipes. + Hard resource limits and parent-death termination are set before parser imports. + +This is **a restricted subprocess inside a shared container, not a disposable container or VM**. +The container and host kernel are shared. Landlock does not hide all filesystem metadata or +provide a separate PID namespace per job. Kernel vulnerabilities and hardware side channels +are outside this boundary. A container-wide OOM can terminate the supervisor and other jobs. +The runtime filesystem is readable, so it must contain no secrets. Do not colocate unrelated +services in the container. A stronger backend can be added later without changing caller policy. + +Authorization, content inspection, identity metadata, chunking, embeddings, ingestion rollback, +and persistence remain with LibreChat/RAG API. Parser output is untrusted content, not trusted +identity or policy. This service neither modifies nor re-enables LibreChat's native LibreOffice path. + +## Verify + +```sh +cd packages/extraction +npm ci --ignore-scripts +npm run typecheck +npm test +npm run test:container +``` + +Unit tests cover request validation, result validation, deadline/cancellation, cleanup failures, +admission and stalled output readers. Container tests build and run the actual Compose recipe, +extract real synthetic PDF/DOCX documents through the endpoint, exercise adversarial archives, +inspect mounts/resources, probe cross-job/credential/network/process access, test worker +termination and restart, and remove their containers/volumes. Fixtures contain no user data. + +Qualification must pass on the deployment host without relaxing capabilities, seccomp, LSMs, +or sysctls. Passing parser tests alone does not qualify a different container/runtime recipe. + +### Qualified runtime + +The shipped recipe passed locally on 2026-10-04 with Docker 29.8.2, Linux +7.0.0-1013-aws (x86_64), Node 24.16.0 and Python 3.13.16. Docker's default +seccomp profile, capability dropping and `no-new-privileges` remained enabled. +Other hosts must run the same qualification before enablement. diff --git a/packages/extraction/compose.yaml b/packages/extraction/compose.yaml new file mode 100644 index 00000000..f5288349 --- /dev/null +++ b/packages/extraction/compose.yaml @@ -0,0 +1,22 @@ +services: + extraction: + image: librechat-extraction:local + build: + context: ../.. + dockerfile: packages/extraction/Dockerfile + target: runtime + init: true + user: '10001:10001' + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + network_mode: none + mem_limit: 768m + cpus: 2 + pids_limit: 32 + tmpfs: + - /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 + volumes: + - extraction-socket:/socket +volumes: + extraction-socket: diff --git a/packages/extraction/native/guard.c b/packages/extraction/native/guard.c new file mode 100644 index 00000000..945c7883 --- /dev/null +++ b/packages/extraction/native/guard.c @@ -0,0 +1,141 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Stable UAPI values missing from Debian 12's older build headers. */ +#ifndef LANDLOCK_ACCESS_FS_TRUNCATE +#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) +#endif +#ifndef __NR_fchmodat2 +#define __NR_fchmodat2 452 +#endif + +#if defined(__x86_64__) +#define ARCH AUDIT_ARCH_X86_64 +#define LOADER "/lib64/ld-linux-x86-64.so.2" +#elif defined(__aarch64__) +#define ARCH AUDIT_ARCH_AARCH64 +#define LOADER "/lib/ld-linux-aarch64.so.1" +#else +#error Unsupported architecture +#endif + +static void fail_at(int line) { fprintf(stderr, "ISOLATION_UNAVAILABLE:%d:%d\n", line, errno); exit(125); } +#define fail() fail_at(__LINE__) +static void limit(int resource, rlim_t value) { + struct rlimit r = {value, value}; + if (setrlimit(resource, &r)) fail(); +} +static void allow(int rules, const char *path, __u64 access) { + int fd = open(path, O_PATH | O_CLOEXEC); + if (fd < 0) fail(); + struct landlock_path_beneath_attr rule = {.allowed_access = access, .parent_fd = fd}; + if (syscall(SYS_landlock_add_rule, rules, LANDLOCK_RULE_PATH_BENEATH, &rule, 0)) fail(); + close(fd); +} +#define DENY(nr) BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM) +static void restrict_syscalls(void) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), +#ifdef __x86_64__ + BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), +#endif + DENY(__NR_socket), DENY(__NR_socketpair), DENY(__NR_connect), + DENY(__NR_bind), DENY(__NR_listen), DENY(__NR_accept), DENY(__NR_accept4), + DENY(__NR_clone), DENY(__NR_clone3), +#ifdef __NR_fork + DENY(__NR_fork), DENY(__NR_vfork), +#endif + DENY(__NR_ptrace), DENY(__NR_process_vm_readv), DENY(__NR_process_vm_writev), + DENY(__NR_kill), DENY(__NR_tkill), DENY(__NR_tgkill), DENY(__NR_pidfd_send_signal), + DENY(__NR_mount), DENY(__NR_umount2), DENY(__NR_pivot_root), DENY(__NR_chroot), + DENY(__NR_unshare), DENY(__NR_setns), DENY(__NR_bpf), DENY(__NR_userfaultfd), + DENY(__NR_io_uring_setup), DENY(__NR_perf_event_open), + DENY(__NR_setpriority), DENY(__NR_sched_setparam), DENY(__NR_sched_setscheduler), + DENY(__NR_sched_setaffinity), DENY(__NR_sched_setattr), + DENY(__NR_pidfd_open), DENY(__NR_pidfd_getfd), + DENY(__NR_keyctl), DENY(__NR_add_key), DENY(__NR_request_key), + DENY(__NR_shmget), DENY(__NR_shmat), DENY(__NR_shmctl), + DENY(__NR_semget), DENY(__NR_semop), DENY(__NR_semctl), + DENY(__NR_msgget), DENY(__NR_msgsnd), DENY(__NR_msgrcv), DENY(__NR_msgctl), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_prlimit64, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + DENY(__NR_utimensat), +#ifdef __NR_utime + DENY(__NR_utime), DENY(__NR_utimes), DENY(__NR_futimesat), +#endif +#ifdef __NR_fchmodat2 + DENY(__NR_fchmodat2), +#endif + DENY(__NR_open_by_handle_at), DENY(__NR_name_to_handle_at), + DENY(__NR_fchmod), DENY(__NR_fchmodat), DENY(__NR_fchown), DENY(__NR_fchownat), +#ifdef __NR_chmod + DENY(__NR_chmod), DENY(__NR_chown), DENY(__NR_lchown), +#endif + DENY(__NR_mknodat), DENY(__NR_setuid), DENY(__NR_setgid), + DENY(__NR_setreuid), DENY(__NR_setregid), DENY(__NR_setresuid), DENY(__NR_setresgid), + DENY(__NR_setgroups), DENY(__NR_setxattr), DENY(__NR_lsetxattr), DENY(__NR_fsetxattr), + DENY(__NR_removexattr), DENY(__NR_lremovexattr), DENY(__NR_fremovexattr), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) + }; + struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) fail(); +} +int main(int argc, char **argv) { + if (argc != 5 || geteuid() == 0) fail(); + /* Paths are supervisor-owned, never supplied in the HTTP protocol. */ + if (chdir(argv[1])) fail(); + pid_t parent = getppid(); + if (parent == 1 || prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != parent) fail(); + limit(RLIMIT_AS, 512ULL * 1024 * 1024); + limit(RLIMIT_CPU, 8); + limit(RLIMIT_FSIZE, 4ULL * 1024 * 1024); + limit(RLIMIT_NOFILE, 64); + limit(RLIMIT_CORE, 0); + if (prctl(PR_SET_DUMPABLE, 0) || prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) fail(); + if (syscall(SYS_close_range, 3, ~0U, 0)) fail(); + int abi = syscall(SYS_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 3) fail(); + __u64 read = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + __u64 write = LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_MAKE_REG; + struct landlock_ruleset_attr attr = { + .handled_access_fs = (1ULL << 15) - 1 /* ABI 3, including REFER and TRUNCATE */ + }; + int rules = syscall(SYS_landlock_create_ruleset, &attr, sizeof(attr), 0); + if (rules < 0) fail(); + allow(rules, "/usr", read); + allow(rules, "/lib", read); + if (access("/lib64", F_OK) == 0) allow(rules, "/lib64", read); + allow(rules, argv[2], LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_EXECUTE); + allow(rules, LOADER, LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_EXECUTE); + allow(rules, argv[3], LANDLOCK_ACCESS_FS_READ_FILE); + allow(rules, argv[1], read | write); + allow(rules, "/dev/null", LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE); + allow(rules, "/dev/urandom", LANDLOCK_ACCESS_FS_READ_FILE); + if (syscall(SYS_landlock_restrict_self, rules, 0)) fail(); + close(rules); + restrict_syscalls(); + if (clearenv() || setenv("LANG", "C.UTF-8", 1)) fail(); + execl(argv[2], argv[2], "-I", "-B", argv[3], argv[4], (char *)NULL); + fail(); +} diff --git a/packages/extraction/package-lock.json b/packages/extraction/package-lock.json new file mode 100644 index 00000000..86e21c9f --- /dev/null +++ b/packages/extraction/package-lock.json @@ -0,0 +1,64 @@ +{ + "name": "@librechat/extraction-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@librechat/extraction-service", + "version": "0.1.0", + "devDependencies": { + "@types/node": "24.10.1", + "prettier": "2.4.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/prettier": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", + "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/extraction/package.json b/packages/extraction/package.json new file mode 100644 index 00000000..c45ec3e5 --- /dev/null +++ b/packages/extraction/package.json @@ -0,0 +1,22 @@ +{ + "name": "@librechat/extraction-service", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test dist/*.test.js", + "start": "node dist/main.js", + "test:container": "bash tests/container.sh", + "format:check": "prettier --check src tests/*.mjs *.json compose.yaml README.md ../../.github/workflows/extraction.yml" + }, + "devDependencies": { + "@types/node": "24.10.1", + "prettier": "2.4.1", + "typescript": "5.9.3" + } +} diff --git a/packages/extraction/src/backend.ts b/packages/extraction/src/backend.ts new file mode 100644 index 00000000..43ca9bd0 --- /dev/null +++ b/packages/extraction/src/backend.ts @@ -0,0 +1,165 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ExtractionError, policy, validateResult } from './protocol.js'; +import type { Format } from './protocol.js'; + +export interface Backend { + healthy: boolean; + create(): Promise; + run( + job: string, + format: Format, + digest: string, + signal: AbortSignal + ): Promise; + cleanup(job: string): Promise; +} +export interface Runtime { + jobs: string; + guard: string; + python: string; + worker: string; +} +const parserErrors = new Set([ + 'INVALID_DOCUMENT', + 'EMPTY_OUTPUT', + 'OUTPUT_LIMIT', + 'DECOMPRESSION_LIMIT', + 'STRUCTURE_LIMIT', + 'ENCRYPTED_DOCUMENT', + 'UNSUPPORTED_ENCODING', + 'RESOURCE_LIMIT', +]); +export class RestrictedBackend implements Backend { + healthy = true; + constructor(private readonly runtime: Runtime) {} + async create(): Promise { + if (!this.healthy) throw new ExtractionError('UNAVAILABLE'); + return mkdtemp(join(this.runtime.jobs, 'job-')); + } + private async execute( + job: string, + mode: string, + signal: AbortSignal + ): Promise { + signal.throwIfAborted(); + const child = spawn( + this.runtime.guard, + [job, this.runtime.python, this.runtime.worker, mode], + { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: {}, + } + ); + let output = ''; + let count = 0; + let overflow = false; + const kill = () => { + if ( + child.pid && + child.exitCode === null && + child.signalCode === null + ) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') + this.healthy = false; + } + } + }; + signal.addEventListener('abort', kill, { once: true }); + if (signal.aborted) kill(); + let spawnError = false; + child.on('error', () => { + spawnError = true; + }); + child.stdout.on('data', (chunk: Buffer) => { + count += chunk.length; + if (count > 1024) { + overflow = true; + kill(); + } else output += chunk.toString('ascii'); + }); + child.stderr.on('data', (chunk: Buffer) => { + count += chunk.length; + if (count > 2048) { + overflow = true; + kill(); + } + }); + const code = await new Promise(resolve => + child.once('close', resolve) + ); + signal.removeEventListener('abort', kill); + signal.throwIfAborted(); + if (spawnError || overflow) throw new ExtractionError('UNAVAILABLE'); + if (code !== 0) { + const value = output.trim(); + if (parserErrors.has(value)) + throw new ExtractionError(value as 'INVALID_DOCUMENT'); + throw new ExtractionError( + code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE' + ); + } + return output; + } + async probe(): Promise { + const job = await this.create(); + try { + const output = await this.execute( + job, + 'probe', + AbortSignal.timeout(policy.deadlineMs) + ); + if (output.trim() !== 'READY') + throw new ExtractionError('UNAVAILABLE'); + } finally { + await this.cleanup(job); + } + } + async run( + job: string, + format: Format, + digest: string, + signal: AbortSignal + ): Promise { + await this.execute(job, format, signal); + const file = await open( + join(job, 'result'), + constants.O_RDONLY | constants.O_NOFOLLOW + ); + try { + const stat = await file.stat(); + if ( + !stat.isFile() || + stat.nlink !== 1 || + stat.size > policy.resultBytes + ) { + throw new ExtractionError('OUTPUT_LIMIT'); + } + const bytes = await file.readFile(); + signal.throwIfAborted(); + validateResult(bytes, format, digest); + return bytes; + } finally { + await file.close(); + } + } + async cleanup(job: string): Promise { + try { + await rm(job, { recursive: true, force: true, maxRetries: 0 }); + } catch { + this.healthy = false; + throw new ExtractionError('UNAVAILABLE'); + } + } +} + +/** Used by focused worker tests, not by HTTP callers. */ +export async function writeInput(job: string, input: Buffer): Promise { + await writeFile(join(job, 'input'), input, { flag: 'wx', mode: 0o600 }); +} diff --git a/packages/extraction/src/boundary.test.ts b/packages/extraction/src/boundary.test.ts new file mode 100644 index 00000000..da54749e --- /dev/null +++ b/packages/extraction/src/boundary.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +test('supervisor has no parser or application stack dependencies', async () => { + const manifest = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8') + ); + assert.equal(manifest.dependencies, undefined); + for (const name of ['server.ts', 'backend.ts', 'protocol.ts', 'main.ts']) { + const source = await readFile( + new URL(`../src/${name}`, import.meta.url), + 'utf8' + ); + for (const match of source.matchAll(/from ['"]([^'"]+)['"]/g)) { + assert.ok( + match[1]?.startsWith('node:') || match[1]?.startsWith('./'), + match[1] + ); + } + } +}); diff --git a/packages/extraction/src/health.ts b/packages/extraction/src/health.ts new file mode 100644 index 00000000..641575c4 --- /dev/null +++ b/packages/extraction/src/health.ts @@ -0,0 +1,15 @@ +import { request } from 'node:http'; +const req = request( + { + socketPath: '/socket/extraction.sock', + path: '/v1/capabilities', + timeout: 1000, + }, + res => { + res.resume(); + res.once('end', () => process.exit(res.statusCode === 200 ? 0 : 1)); + } +); +req.on('timeout', () => req.destroy()); +req.on('error', () => process.exit(1)); +req.end(); diff --git a/packages/extraction/src/main.ts b/packages/extraction/src/main.ts new file mode 100644 index 00000000..424a6ed6 --- /dev/null +++ b/packages/extraction/src/main.ts @@ -0,0 +1,64 @@ +import { chmod, lstat, readdir, unlink } from 'node:fs/promises'; +import { connect } from 'node:net'; +import { RestrictedBackend } from './backend.js'; +import { extractionServer } from './server.js'; + +// Deployment-owned locations, not caller-selected paths or runtimes. +const backend = new RestrictedBackend({ + jobs: '/jobs', + guard: '/opt/extraction/guard', + python: '/usr/local/bin/python3.13', + worker: '/opt/extraction/extract.py', +}); +try { + if ((await readdir('/jobs')).length) throw new Error('DIRTY_SCRATCH'); + try { + const stat = await lstat('/socket/extraction.sock'); + if (!stat.isSocket()) throw new Error('INVALID_SOCKET'); + await new Promise((resolve, reject) => { + const client = connect('/socket/extraction.sock'); + client.setTimeout(1000, () => { + client.destroy(); + reject(new Error('SOCKET_EXISTS')); + }); + client.once('connect', () => { + client.destroy(); + reject(new Error('SOCKET_EXISTS')); + }); + client.once('error', error => { + if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') + resolve(); + else reject(error); + }); + }); + await unlink('/socket/extraction.sock'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await backend.probe(); + const server = extractionServer(backend); + server.on('error', () => { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); + }); + process.umask(0o007); + server.listen('/socket/extraction.sock', async () => { + try { + await chmod('/socket/extraction.sock', 0o660); + } catch { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); + } + console.log('Extraction service ready'); + }); + const shutdown = () => { + server.close(); + server.closeAllConnections(); + setTimeout(() => process.exit(0), 12_000).unref(); + }; + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); +} catch { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); +} diff --git a/packages/extraction/src/protocol.test.ts b/packages/extraction/src/protocol.test.ts new file mode 100644 index 00000000..6688fa49 --- /dev/null +++ b/packages/extraction/src/protocol.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { policy, validateResult } from './protocol.js'; +const digest = 'a'.repeat(64); +const value = (text = 'hello') => ({ + version: 1, + operation: 'document.extract-text', + format: 'pdf', + inputSha256: digest, + textBytes: Buffer.byteLength(text), + segments: [{ kind: 'page', index: 1, text }], +}); +const encode = (result: unknown) => Buffer.from(JSON.stringify(result)); +test('preserves ordered page segments and validates digest and byte count', () => { + const result = value(); + assert.deepEqual(validateResult(encode(result), 'pdf', digest), result); + for (const changed of [ + { ...result, inputSha256: 'b'.repeat(64) }, + { ...result, textBytes: 4 }, + { ...result, extra: true }, + { ...result, format: 'docx' }, + { ...result, segments: [{ kind: 'page', index: 0, text: 'hello' }] }, + { ...result, segments: [{ kind: 'sheet', index: 1, text: 'hello' }] }, + ]) + assert.throws(() => validateResult(encode(changed), 'pdf', digest)); +}); +test('rejects malformed UTF-8, lone surrogates, emptiness and invalid JSON', () => { + for (const bytes of [ + Buffer.from([0xff]), + Buffer.from('{'), + encode(value('\ud800')), + encode(value(' ')), + encode(null), + ]) { + assert.throws(() => validateResult(bytes, 'pdf', digest)); + } +}); +test('accepts exact text limit and rejects one byte more or oversized wire result', () => { + assert.equal( + validateResult( + encode(value('x'.repeat(policy.textBytes))), + 'pdf', + digest + ).textBytes, + policy.textBytes + ); + assert.throws(() => + validateResult( + encode(value('x'.repeat(policy.textBytes + 1))), + 'pdf', + digest + ) + ); + assert.throws(() => + validateResult(Buffer.alloc(policy.resultBytes + 1), 'pdf', digest) + ); +}); +test('rejects excess pages and does not accept PDF pages as DOCX structure', () => { + const result = value(); + result.segments = Array.from( + { length: policy.maxSegments + 1 }, + (_, index) => ({ kind: 'page', index: index + 1, text: 'hello' }) + ); + assert.throws(() => validateResult(encode(result), 'pdf', digest)); + assert.throws(() => + validateResult(encode({ ...value(), format: 'docx' }), 'docx', digest) + ); +}); diff --git a/packages/extraction/src/protocol.ts b/packages/extraction/src/protocol.ts new file mode 100644 index 00000000..4ccd22e2 --- /dev/null +++ b/packages/extraction/src/protocol.ts @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto'; + +export const policy = Object.freeze({ + inputBytes: 10 * 1024 * 1024, + textBytes: 1024 * 1024, + resultBytes: 3 * 1024 * 1024, + deadlineMs: 10_000, + maxSegments: 128, +}); +export type Format = 'pdf' | 'docx'; +export type ErrorCode = + | 'INVALID_REQUEST' + | 'INPUT_LIMIT' + | 'BUSY' + | 'DEADLINE' + | 'CANCELLED' + | 'UNAVAILABLE' + | 'INVALID_DOCUMENT' + | 'EMPTY_OUTPUT' + | 'OUTPUT_LIMIT' + | 'DECOMPRESSION_LIMIT' + | 'STRUCTURE_LIMIT' + | 'ENCRYPTED_DOCUMENT' + | 'UNSUPPORTED_ENCODING' + | 'RESOURCE_LIMIT'; +export class ExtractionError extends Error { + constructor(readonly code: ErrorCode) { + super(code); + } +} +export interface Segment { + kind: 'page' | 'document'; + index: number; + text: string; +} +export interface Result { + version: 1; + operation: 'document.extract-text'; + format: Format; + inputSha256: string; + textBytes: number; + segments: Segment[]; +} +const keys = (value: object, expected: string[]) => + Object.keys(value).length === expected.length && + expected.every(key => Object.hasOwn(value, key)); +export function validateResult( + bytes: Buffer, + format: Format, + digest: string +): Result { + if (bytes.length > policy.resultBytes) + throw new ExtractionError('OUTPUT_LIMIT'); + let result: Result; + try { + result = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) as Result; + } catch { + throw new ExtractionError('INVALID_DOCUMENT'); + } + if ( + !result || + typeof result !== 'object' || + !keys(result, [ + 'version', + 'operation', + 'format', + 'inputSha256', + 'textBytes', + 'segments', + ]) || + result.version !== 1 || + result.operation !== 'document.extract-text' || + result.format !== format || + result.inputSha256 !== digest || + !Array.isArray(result.segments) || + !result.segments.length || + result.segments.length > (format === 'pdf' ? policy.maxSegments : 1) + ) { + throw new ExtractionError('INVALID_DOCUMENT'); + } + let size = 0; + let nonempty = false; + for (const [index, segment] of result.segments.entries()) { + if ( + !segment || + typeof segment !== 'object' || + !keys(segment, ['kind', 'index', 'text']) || + segment.kind !== (format === 'pdf' ? 'page' : 'document') || + segment.index !== index + 1 || + typeof segment.text !== 'string' || + !segment.text.isWellFormed() + ) + throw new ExtractionError('INVALID_DOCUMENT'); + size += Buffer.byteLength(segment.text); + nonempty ||= Boolean(segment.text.trim()); + } + if (size > policy.textBytes) throw new ExtractionError('OUTPUT_LIMIT'); + if (size !== result.textBytes || !nonempty) + throw new ExtractionError('INVALID_DOCUMENT'); + return result; +} +export function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/packages/extraction/src/server.test.ts b/packages/extraction/src/server.test.ts new file mode 100644 index 00000000..0a682e7b --- /dev/null +++ b/packages/extraction/src/server.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readdir } from 'node:fs/promises'; +import { request } from 'node:http'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { once } from 'node:events'; +import { test } from 'node:test'; +import { extractionServer } from './server.js'; +import { ExtractionError, policy } from './protocol.js'; +import type { Backend } from './backend.js'; + +async function fixture(deadline = 500) { + const root = await mkdtemp(join(tmpdir(), 'extraction-test-')); + const events: string[] = []; + const backend: Backend = { + healthy: true, + create: async () => { + events.push('create'); + return mkdtemp(join(root, 'job-')); + }, + run: async () => { + events.push('run'); + return Buffer.from('{"segments":[]}'); + }, + cleanup: async job => { + events.push('cleanup'); + await rm(job, { recursive: true }); + }, + }; + const server = extractionServer(backend, deadline); + const socketPath = join(root, 'service.sock'); + server.listen(socketPath); + await once(server, 'listening'); + return { + backend, + events, + root, + socketPath, + server, + close: async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + }, + }; +} +const headers = { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '4', + 'X-Extraction-Version': '1', + 'X-Extraction-Format': 'pdf', +}; +async function send( + socketPath: string, + overrides = {}, + path = '/v1/extract-text' +) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request( + { + socketPath, + path, + method: 'POST', + headers: { ...headers, ...overrides }, + }, + res => { + let body = ''; + res.on('data', bytes => { + body += bytes; + }); + res.on('end', () => resolve({ status: res.statusCode!, body })); + } + ); + req.on('error', reject); + req.end('test'); + }); +} +test('binary upload executes once and cleanup completes before success', async () => { + const f = await fixture(); + try { + assert.equal((await send(f.socketPath)).status, 200); + assert.deepEqual(f.events, ['create', 'run', 'cleanup']); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } finally { + await f.close(); + } +}); +test('closed parameters and declared input limit are rejected before admission', async () => { + const f = await fixture(); + try { + for (const changes of [ + { 'X-Extraction-Format': 'xls' }, + { 'X-Extraction-Version': '2' }, + { 'X-Extraction-Url': 'https://example.com' }, + { 'Content-Type': 'text/plain' }, + ]) { + assert.equal((await send(f.socketPath, changes)).status, 400); + } + assert.equal( + (await send(f.socketPath, {}, '/v1/extract-text?path=x')).status, + 404 + ); + assert.equal( + ( + await send(f.socketPath, { + 'Content-Length': String(policy.inputBytes + 1), + }) + ).status, + 413 + ); + assert.equal(f.events.length, 0); + } finally { + await f.close(); + } +}); +test('cleanup failure suppresses success and quarantines backend', async () => { + const f = await fixture(); + f.backend.cleanup = async () => { + f.backend.healthy = false; + throw new ExtractionError('UNAVAILABLE'); + }; + try { + assert.equal((await send(f.socketPath)).status, 503); + assert.equal((await send(f.socketPath)).status, 503); + assert.equal(f.events.filter(e => e === 'run').length, 1); + } finally { + await f.close(); + } +}); +test('deadline aborts execution and waits for its close before cleanup', async () => { + const f = await fixture(50); + f.backend.run = async (_job, _format, _digest, signal) => { + await new Promise(resolve => + signal.addEventListener( + 'abort', + () => { + setTimeout(() => { + f.events.push('closed'); + resolve(); + }, 15); + }, + { once: true } + ) + ); + signal.throwIfAborted(); + return Buffer.alloc(0); + }; + try { + assert.equal((await send(f.socketPath)).status, 504); + assert.deepEqual(f.events, ['create', 'closed', 'cleanup']); + } finally { + await f.close(); + } +}); +test('upload disconnect and stalled upload both release scratch and admission', async () => { + const f = await fixture(60); + try { + for (const disconnect of [true, false]) { + const req = request({ + socketPath: f.socketPath, + path: '/v1/extract-text', + method: 'POST', + headers, + }); + req.on('error', () => {}); + req.flushHeaders(); + req.write('t'); + await new Promise(resolve => setTimeout(resolve, 20)); + if (disconnect) req.destroy(); + await new Promise(resolve => setTimeout(resolve, 80)); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } + assert.equal(f.events.includes('run'), false); + assert.equal((await send(f.socketPath)).status, 200); + } finally { + await f.close(); + } +}); +test('two active jobs bound concurrency without a queue', async () => { + const f = await fixture(200); + let active = 0; + f.backend.run = async (_job, _format, _digest, signal) => { + active++; + await new Promise(resolve => + signal.addEventListener('abort', () => resolve(), { once: true }) + ); + signal.throwIfAborted(); + return Buffer.alloc(0); + }; + try { + const a = send(f.socketPath), + b = send(f.socketPath); + while (active !== 2) + await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal((await send(f.socketPath)).status, 429); + assert.equal((await a).status, 504); + assert.equal((await b).status, 504); + } finally { + await f.close(); + } +}); +test('deadline closes stalled output readers while keeping their slots admitted', async () => { + const f = await fixture(250); + f.backend.run = async () => Buffer.alloc(policy.resultBytes, 'a'); + const clients = [0, 1].map(() => { + const req = request( + { + socketPath: f.socketPath, + path: '/v1/extract-text', + method: 'POST', + headers, + }, + res => { + res.pause(); + res.on('error', () => {}); + } + ); + req.on('error', () => {}); + req.end('test'); + return req; + }); + try { + while (f.events.filter(e => e === 'cleanup').length !== 2) + await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal((await send(f.socketPath)).status, 429); + await new Promise(resolve => setTimeout(resolve, 300)); + f.backend.run = async () => Buffer.from('{}'); + assert.equal((await send(f.socketPath)).status, 200); + } finally { + clients.forEach(client => client.destroy()); + await f.close(); + } +}); +test('duplicate extraction control headers are rejected before worker creation', async () => { + const f = await fixture(); + try { + assert.equal( + ( + await send(f.socketPath, { + 'X-Extraction-Format': ['pdf', 'pdf'], + }) + ).status, + 400 + ); + assert.equal(f.events.length, 0); + } finally { + await f.close(); + } +}); diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts new file mode 100644 index 00000000..bfa98646 --- /dev/null +++ b/packages/extraction/src/server.ts @@ -0,0 +1,255 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ExtractionError, policy } from './protocol.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Backend } from './backend.js'; +import type { Format } from './protocol.js'; + +export const capabilities = Object.freeze({ + version: 1, + operation: 'document.extract-text', + formats: ['pdf', 'docx'], + structure: { + pdf: 'ordered pages, one-based index', + docx: 'document body, paragraphs and tables', + }, + limits: { + ...policy, + concurrency: 2, + expandedBytes: 32 * 1024 * 1024, + archiveEntries: 512, + }, +}); +const status = (code: string): number => { + if (code === 'BUSY') return 429; + if (code === 'UNAVAILABLE') return 503; + if (code === 'DEADLINE') return 504; + if ( + code === 'INPUT_LIMIT' || + code === 'OUTPUT_LIMIT' || + code === 'DECOMPRESSION_LIMIT' || + code === 'STRUCTURE_LIMIT' + ) + return 413; + if (code === 'INVALID_REQUEST') return 400; + return 422; +}; +function reply(res: ServerResponse, code: number, bytes: Buffer): void { + res.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': bytes.length, + Connection: 'close', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(bytes); +} +function requestFormat(req: IncomingMessage): Format { + const seen = new Set(); + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]!.toLowerCase(); + if ( + name.startsWith('x-extraction-') && + !['x-extraction-version', 'x-extraction-format'].includes(name) + ) { + throw new ExtractionError('INVALID_REQUEST'); + } + if ( + [ + 'content-length', + 'content-type', + 'x-extraction-version', + 'x-extraction-format', + ].includes(name) + ) { + if (seen.has(name)) throw new ExtractionError('INVALID_REQUEST'); + seen.add(name); + } + } + if ( + req.headers['content-type'] !== 'application/octet-stream' || + req.headers['x-extraction-version'] !== '1' || + req.headers['transfer-encoding'] + ) { + throw new ExtractionError('INVALID_REQUEST'); + } + const format = req.headers['x-extraction-format']; + if (format !== 'pdf' && format !== 'docx') + throw new ExtractionError('INVALID_REQUEST'); + const length = req.headers['content-length']; + if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length)) + throw new ExtractionError('INVALID_REQUEST'); + if ( + !Number.isSafeInteger(Number(length)) || + Number(length) > policy.inputBytes + ) + throw new ExtractionError('INPUT_LIMIT'); + return format; +} +export function extractionServer( + backend: Backend, + deadlineMs: number = policy.deadlineMs +) { + if ( + !Number.isInteger(deadlineMs) || + deadlineMs <= 0 || + deadlineMs > policy.deadlineMs + ) + throw new Error('INVALID_DEADLINE'); + let admitted = 0; + const server = createServer( + { + maxHeaderSize: 4096, + requireHostHeader: true, + connectionsCheckingInterval: 1000, + }, + (req, res) => { + void handle(req, res); + } + ); + server.maxConnections = 16; + server.maxRequestsPerSocket = 1; + server.headersTimeout = 5000; + server.requestTimeout = policy.deadlineMs; + server.setTimeout(policy.deadlineMs, socket => socket.destroy()); + server.on('checkContinue', (req, res) => { + reply(res, 400, Buffer.from('{"error":{"code":"INVALID_REQUEST"}}')); + }); + async function handle( + req: IncomingMessage, + res: ServerResponse + ): Promise { + if (req.method === 'GET' && req.url === '/v1/capabilities') { + reply( + res, + backend.healthy ? 200 : 503, + Buffer.from(JSON.stringify(capabilities)) + ); + return; + } + if (req.method !== 'POST' || req.url !== '/v1/extract-text') { + reply( + res, + 404, + Buffer.from('{"error":{"code":"INVALID_REQUEST"}}') + ); + return; + } + let format: Format; + try { + format = requestFormat(req); + } catch (error) { + const code = + error instanceof ExtractionError + ? error.code + : 'INVALID_REQUEST'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + return; + } + if (!backend.healthy || admitted >= 2) { + const code = backend.healthy ? 'BUSY' : 'UNAVAILABLE'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + return; + } + admitted++; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new ExtractionError('DEADLINE')), + deadlineMs + ); + const aborted = () => + controller.abort(new ExtractionError('CANCELLED')); + controller.signal.addEventListener( + 'abort', + () => { + if (!req.complete) req.destroy(); + if (res.headersSent) res.destroy(); + }, + { once: true } + ); + req.once('aborted', aborted); + res.once('close', () => { + if (!res.writableFinished) aborted(); + }); + const delivered = new Promise(resolve => { + res.once('finish', resolve); + res.once('close', resolve); + }); + let job: string | undefined; + let bytes: Buffer | undefined; + let failure: unknown; + try { + controller.signal.throwIfAborted(); + job = await backend.create(); + const file = await open(join(job, 'input'), 'wx', 0o600); + const digest = createHash('sha256'); + let size = 0; + try { + for await (const chunk of req) { + controller.signal.throwIfAborted(); + size += chunk.length; + if (size > policy.inputBytes) + throw new ExtractionError('INPUT_LIMIT'); + digest.update(chunk); + await file.writeFile(chunk); + } + } finally { + await file.close(); + } + if (size !== Number(req.headers['content-length'])) + throw new ExtractionError('INVALID_REQUEST'); + controller.signal.throwIfAborted(); + bytes = await backend.run( + job, + format, + digest.digest('hex'), + controller.signal + ); + } catch (error) { + failure = error; + } finally { + if (job) { + try { + await backend.cleanup(job); + } catch (error) { + failure = error; + } + } + } + try { + if (controller.signal.aborted && !failure) + failure = controller.signal.reason; + if (controller.signal.reason instanceof ExtractionError) + failure = controller.signal.reason; + if (!res.destroyed) { + if (failure || !bytes) { + const code = + failure instanceof ExtractionError + ? failure.code + : 'UNAVAILABLE'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + } else reply(res, 200, bytes); + } + await delivered; + } finally { + clearTimeout(timeout); + req.removeListener('aborted', aborted); + admitted--; + } + } + return server; +} diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py new file mode 100644 index 00000000..fa5481bb --- /dev/null +++ b/packages/extraction/tests/adversarial.py @@ -0,0 +1,44 @@ +"""Create bounded adversarial test documents inside the disposable test container.""" +from pathlib import Path +import struct +import zipfile +from docx import Document +from pypdf import PdfReader, PdfWriter + +root = Path('/tests/fixtures') +(root / 'bad.docx').write_bytes(b'PK\x03\x04not a zip') +with zipfile.ZipFile(root / 'entries.docx', 'w') as file: + for index in range(513): + file.writestr(str(index), 'x') +with zipfile.ZipFile(root / 'duplicate.docx', 'w') as file: + file.writestr('same', 'a') + file.writestr('same', 'b') +with zipfile.ZipFile(root / 'bomb.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + file.writestr('content.xml', b'x' * (8 * 1024 * 1024 + 1)) +with zipfile.ZipFile(root / 'aggregate.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + for index in range(5): + file.writestr(str(index), b'x' * (7 * 1024 * 1024)) +for name, value in [('utf8.docx', b'\xff'), ('entities.docx', b']>')]: + with zipfile.ZipFile(root / name, 'w') as file: + file.writestr('word/document.xml', value) +with zipfile.ZipFile(root / 'forged.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + file.writestr('content.xml', 'forged-content' * 20) +value = bytearray((root / 'forged.docx').read_bytes()) +central = value.index(b'PK\x01\x02') +struct.pack_into(' +#include +#include +#include +#include +#include + +/* Negative startup probe: add a restriction to Docker's existing filter. */ +int main(void) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_landlock_create_ruleset, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 38), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) + }; + struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) return 125; + execl("/usr/local/bin/node", "node", "/opt/extraction/dist/main.js", (char *)NULL); + return 125; +} diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs new file mode 100644 index 00000000..85dcea91 --- /dev/null +++ b/packages/extraction/tests/container-client.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { request } from 'node:http'; +import { createHash } from 'node:crypto'; + +const socketPath = '/socket/extraction.sock'; +function send(format, bytes, path = '/v1/extract-text', extra = {}) { + return new Promise((resolve, reject) => { + const req = request( + { + socketPath, + path, + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'X-Extraction-Version': '1', + 'X-Extraction-Format': format, + ...extra, + }, + }, + res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode, + value: JSON.parse( + Buffer.concat(chunks).toString('utf8') + ), + }) + ); + res.on('error', reject); + } + ); + req.on('error', reject); + req.end(bytes); + }); +} +const fixtures = '/tests/fixtures'; +const pages = readFileSync(`${fixtures}/pages.pdf`); +const result = await send('pdf', pages); +assert.equal(result.status, 200); +assert.deepEqual( + result.value.segments.map(s => [s.kind, s.index, s.text]), + [ + ['page', 1, 'First page text'], + ['page', 2, 'Second page text'], + ] +); +assert.equal( + result.value.inputSha256, + createHash('sha256').update(pages).digest('hex') +); +assert.equal( + (await send('pdf', readFileSync(`${fixtures}/compressed.pdf`))).value + .segments[0].text, + 'Compressed page text' +); +const docx = await send('docx', readFileSync(`${fixtures}/body.docx`)); +assert.equal(docx.status, 200); +assert.deepEqual(docx.value.segments, [ + { + kind: 'document', + index: 1, + text: 'First paragraph\nLeft cell\tRight cell\nLast paragraph\n', + }, +]); +for (const [name, format, code] of [ + ['empty.pdf', 'pdf', 'EMPTY_OUTPUT'], + ['empty.docx', 'docx', 'EMPTY_OUTPUT'], + ['inflate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['aggregate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['aggregate.docx', 'docx', 'DECOMPRESSION_LIMIT'], + ['bad.docx', 'docx', 'INVALID_DOCUMENT'], + ['entries.docx', 'docx', 'STRUCTURE_LIMIT'], + ['duplicate.docx', 'docx', 'INVALID_DOCUMENT'], + ['forged.docx', 'docx', 'INVALID_DOCUMENT'], + ['utf8.docx', 'docx', 'INVALID_DOCUMENT'], + ['entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['bomb.docx', 'docx', 'DECOMPRESSION_LIMIT'], + ['output.docx', 'docx', 'OUTPUT_LIMIT'], + ['encrypted.pdf', 'pdf', 'ENCRYPTED_DOCUMENT'], + ['too-many-pages.pdf', 'pdf', 'STRUCTURE_LIMIT'], +]) { + const response = await send(format, readFileSync(`${fixtures}/${name}`)); + assert.equal( + response.value.error?.code, + code, + `${name}: ${JSON.stringify(response)}` + ); +} +const exact = await send('docx', readFileSync(`${fixtures}/exact.docx`)); +assert.equal(exact.status, 200); +assert.equal(exact.value.textBytes, 1024 * 1024); +assert.equal( + (await send('pdf', Buffer.from('not a pdf'))).value.error.code, + 'INVALID_DOCUMENT' +); +assert.equal((await send('docx', pages)).value.error.code, 'INVALID_DOCUMENT'); +assert.equal((await send('xls', pages)).status, 400); +assert.equal((await send('pdf', pages, '/exec')).status, 404); +assert.equal( + (await send('pdf', pages, '/v1/extract-text?script=x')).status, + 404 +); +assert.equal( + ( + await send('pdf', pages, '/v1/extract-text', { + 'X-Extraction-Url': 'http://example.com', + }) + ).status, + 400 +); +// Exact input cap is accepted; one byte more is rejected before parsing. +const padded = Buffer.alloc(10 * 1024 * 1024, 32); +pages.copy(padded); +assert.equal((await send('pdf', padded)).status, 200); +assert.equal((await send('pdf', Buffer.alloc(padded.length + 1))).status, 413); +console.log( + 'Container endpoint: PDF/DOCX success, structure, input limits and 15 adversarial fixtures passed' +); diff --git a/packages/extraction/tests/container.sh b/packages/extraction/tests/container.sh new file mode 100755 index 00000000..6f33b89e --- /dev/null +++ b/packages/extraction/tests/container.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/../../.." +project="extraction-test-$$" +image="librechat-extraction-test:$$" +compose=(docker compose -p "$project" -f packages/extraction/compose.yaml) +cleanup() { + "${compose[@]}" down -v --remove-orphans >/dev/null + docker image rm "$image" >/dev/null 2>&1 || true +} +trap cleanup EXIT +"${compose[@]}" build extraction +docker build --target qualification -t "$image" -f packages/extraction/Dockerfile . +"${compose[@]}" up -d --wait --wait-timeout 30 extraction +id=$("${compose[@]}" ps -q extraction) +volume="${project}_extraction-socket" +# Inspect exactly the shipped recipe, not a relaxed test variant. +docker inspect "$id" | python3 -c ' +import json,sys +c=json.load(sys.stdin)[0]; h=c["HostConfig"] +assert h["NetworkMode"] == "none" and h["ReadonlyRootfs"] and not h["Privileged"] +assert h["CapDrop"] == ["ALL"] and h["PidsLimit"] == 32 +assert h["Memory"] == 768*1024*1024 and h["NanoCpus"] == 2*10**9 +assert "no-new-privileges:true" in h["SecurityOpt"] +assert c["Config"]["User"] == "10001:10001" +assert len(c["Mounts"]) == 1 and c["Mounts"][0]["Destination"] == "/socket" +assert set(h["Tmpfs"]) == {"/jobs"} +for flag in ("noexec", "nosuid", "nodev", "size=96m", "nr_inodes=1024", "uid=10001", "gid=10001", "mode=0700"): + assert flag in h["Tmpfs"]["/jobs"] +assert not c["Config"]["ExposedPorts"] if "ExposedPorts" in c["Config"] else True +print("Shipped Compose mounts, resource limits and security options verified")' +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=volume,src=$volume,dst=/socket,readonly" "$image" \ + sh -c 'python /tests/adversarial.py && node /tests/container-client.mjs' +docker exec "$id" node -e 'require("node:fs").readdir("/jobs",(e,v)=>{if(e||v.length)process.exit(1); console.log("Endpoint scratch cleanup verified")})' +# Same outer boundary, with a test-only worker that attempts forbidden operations. +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 768m --cpus 2 --pids-limit 32 \ + --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 \ + --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + "$image" node /tests/isolation.mjs +# Denying the mandatory isolation syscall must fail closed, never start unsandboxed. +set +e +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 768m --cpus 2 --pids-limit 32 \ + --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,uid=10001,gid=10001,mode=0700 \ + --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + "$image" /tests/block-landlock > /dev/null 2>&1 +blocked=$? +set -e +if [ "$blocked" -ne 1 ]; then + printf 'FAIL: missing isolation did not reach fail-closed startup (exit %s)\n' "$blocked" >&2 + exit 1 +fi +printf 'Unavailable isolation fails closed\n' +# A stale socket left by SIGKILL must not prevent a safe restart. +docker kill -s KILL "$id" >/dev/null +"${compose[@]}" up -d --wait --wait-timeout 30 extraction +printf 'Hardened container restart passed\n' diff --git a/packages/extraction/tests/fixtures/aggregate.pdf b/packages/extraction/tests/fixtures/aggregate.pdf new file mode 100644 index 0000000000000000000000000000000000000000..70f476e3c7eb8a7c6236c212119a3d2c9d29752b GIT binary patch literal 37605 zcmeI*KTH#09L8~Ud>u4l(D7YJBt))v<$5h4kN{SV8c8u~Vn{sb3)a*gxgH8mXrgg3 zadE=INn;${j0~!_Yz9)z4zYtcc0$&$jy3!j>r+jNcG_eZm?EgW}AD3gz+Xx#{9$r|9n&s!}wEotkbJ=cbjkM*JyN z71mHNQ;DQ2th1$JRbG{@?pTms{F#eCZ8Jqd+UfY;Sk0iOC7F9=&>=ta%fXZ`#f&b^ ztc98rdKym12bD-zqch`CT$CBb7S^C&RpyAaPOI`Q6_qCZno&%N8>cn139F#0!Av-z z=QSAd`SBN9nt9_oG>COx6-)kLFsm;%)t!p}GPWbVoFm6f;i1wKuT-veDv{o;%1koC zs;AjcYRH$$kw&-jWj|8GY9c7=V$P|`RCHZ>cGfecSEEq*H^uDJ@9*xm>1|#<`P+H) zr7+*w5I_I{1Q0*~0R#|0009ILK%kWc=9ll^Zf`pv#kE&&3ta<`7N50$K6vlS!%II7 zzj^TZNGo4D*Mzw1WQ)6%Z`%4n&kc{F#tlgoB%FKdexeuz z5I_I{1Q0*~0R#|0009K{m;edq9{WKk2LS{SKmY**5I_KdtqU}naK=mKCgI|)MV$4k z?dnBs*Mn9f&aK}CjDP?F2q1s}0tg_000IagfI#aC5OKEdZ_AY-fB*srAbT7nKo^+<}T*q;} z`Z=6jHf`7U%jGgP<3*ugDyvZR^$BaVbVnIKs}KZ{G>LIRSZ6Aefz*SnEXP}o{4k28 P)1KpGL`TQT3wiMm|Keg% literal 0 HcmV?d00001 diff --git a/packages/extraction/tests/fixtures/body.docx b/packages/extraction/tests/fixtures/body.docx new file mode 100644 index 0000000000000000000000000000000000000000..85a4932cc073c3d721bdb7f4d90b5496aba1ccde GIT binary patch literal 36732 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 iqqheU`h{DOvKVUXFuambZ zPo=$j@^9(Ag$BHMiU4V!Ahm@ppM-90pXTvGK(Dksf>2Y&xToVibcg`8)hPMTV zGlpquc0cnFrJ3PqMU+WHd(=@0fp|rE&wRk3VT~6?ll%n3Zw0)${^GZtdi?O957^zL!M}5t=P8o{0b literal 0 HcmV?d00001 diff --git a/packages/extraction/tests/fixtures/empty.docx b/packages/extraction/tests/fixtures/empty.docx new file mode 100644 index 0000000000000000000000000000000000000000..38ea79cef69de713a138c159aa6d4c735f1d6973 GIT binary patch literal 36563 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

A|a1+oFLgqtX6Tr}41ux*#xhNX)NU>gfzOVxqF z{%mBj8UNn1e|=!v}D9H z9y>>bk~-ZQ`W{)xj1*qW3GvIdJl_-87@>NTGKJfuYw)r`jWjpR+gGzi@zwM|Kn%w^ zGEM>9Fw|o>L0fHRYyGTJD}WEReUx$C#`tmBP|qxxC-u@6ir#UA?R7H&-3wwrKKB;- z5r&-*fls(T`VsaS%bj;@5OQPSYTT?mrIU{3VzI*x;3NkxkjW{Q74 z;Ae?ND{PLN!cIGv)XuGO`%3hKCd^Q^LI*D8VJ$vu$n$a!ruVfk@wg@fzk|VoI7h)5 zQg8raD0w0Ae0$~bV@x!PVBYO>_x`(`qFdv~t;KJj9?0D8xAC9=f&Sc~O3VnRR=)%# z(k&lyL~8251DuYVl>Xou{>9(~SB>fu_?j7ccT38k=HyhG{O{TW*8J8VYa1q8wp-R8 zHyI3wKgsP;TLWMH*2gdR_#shSGtZCs4<4Nz9Dk8_SK|JP_5W78h+CWD331*G1ROZb z^pmMy(H6@l&9qE%h}9I^8Qv^}`ULyS^;W`)OwBOj?Gqb zCQPoLHP@&ID6Z?-K|CAF%cMB={V-q`Ce=Fcd0ptQf^i{B>`FA%E zEatolp|T&-THnH&`;3V=;x!5dB%?PbHN1Ns^QM~z|5$)B(-xCvL@*ucKQhcR%H8a1<{9*nP$v2&-aPAMK-hxEEu{$ zC%Rqt97C%q&=W}GgV5+n`gwSRgZWs4)htk55T{k>7wLyOGKqw$iz zSluRNb(Z|-@>;$K81SkZZb_JD@$zt!yWAuH?YT+Nuk-7!Ht9f8#bxqrIh4TXV6OR` zz{`8Tq%_CpXze)1H{WG)IaS}sY`wC!`+0etcgv&I=C~%7f9r01>-(#~`^Ds-!2=$* zXVsJY_Nl`U@7K+zcMD%G-`&01UcHz1Sb@(1H|9S0Bz{Z1Rj?59AW~0l%s~zn|M=^D zXScx!j8nbJkk}cW!~~IOyjeE~IBeBIW6*7LRf~3Nk7*oz4Gihi?53PGxRY zePzY>QYOzrIlbj)0r0@(k`4UJn%JZ=7Bpfd{u_6EJ7gc=&B!Fe3>qHiU{I=eiCr7C zbmCNC3-uIm%nj7-fPn^}7z0DJGjK3CHLoN-q_QBjI2P18LpPvO&D-GzQ0YD3I5Ia( zH;`^%JO(rZ(#8ij?a?)Mv~dS#0+n0@Y7|7#_#7w#uX~d7i@+n%=-R*BzAgS5s6B^; zfk6O8dp%GDu064!0Gq4%awZ)64%E#9^fs#HGk_xCnFF8^rMW=AW7D43AT4AB)P5Rh zl?;mI`#7N5AwEYo1Zya;Us*r1SRdFF4rOKFM=?nYI4BQxNO4JJPAYi15cMi}wD7He-D-2n8CF2aEQb;t%_>-M6Xh2D2Wn6;xG z*(}taB)Tc+T`hzu=Nq7*g3|Fq*N@&FMCi|ILCRvNt-}CsR$vYSqWp D`Bw}5 literal 0 HcmV?d00001 diff --git a/packages/extraction/tests/fixtures/empty.pdf b/packages/extraction/tests/fixtures/empty.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fc99c8291dbc2292fb29c408fdfbd0776a0fe03a GIT binary patch literal 660 zcmZWn-A=+V6uv)Cac-8tg?8Q8hza2WWYMURjNKV83hQtLSJHI`d`T~S1AP%sD?bY~ zS+agT=leOWH@!`+_;m>0&+peaaP(2lS1=d=n`)Ie3#qZQ*=_Qo3&SDED!0c$t1R1X zB(kYk${N^MHI+dQ?4itSe8GqcQ+#c;h8L}4VZ>T3Nzihjq{nYIK;NekLOY?>yw;pSwrFe%?hAiPoyjg*L zVh?=Ga_n?5Tj3Bu7NV8dXycDsP_VQRv`uAUB_VItyJWfh4~{pmu=94`d*AP;{??i+(RnfJv)ZrU zU%xW}9_Y=xY+-@%rqM~Sr3{?uZg-RRS+=;yRGzrc^~!HIx4Q~>Q+8Cr__FTh76jwh z(xiY}5a5q7+^)3xW39{5%1n1~p_>72c3f$=FxC+1+%mq=+qC6sCjd7t$wIj=z%Q%p zzOrdcR;D;1Lt}@E8IM(=d!~hZo#nE>d=cRGmO9Wjzpj#0F6lm6@@BpA2}K>|8g(>> z!i~bk|CJ+y+~T!>a5!N=&E1C@qBOIJmPeViYDKklq8MLOdB@&?dC_P%>4h~)Zn6He z-Ou9WN0>h5ozuIKl$}eTo_>4v=s3*0dwuoC#hvGSU+#T)$=9do5CI}U1c(3;AOb{y z2oM1xKm>>Y5g-B+OyI`Du7~!H9$9;#;neBXy{RW}PfW01vPJ}m01+SpM1Tko0U|&I zhyW2F0z_c42t5Az{!P&M%A%m}B0-fiMXC0NQX+6PSE(2lB&O zfZ2wTnh+Sw7_EhvXc(#0{qdR~R_0kF(@dE%L9&rPP$-YbT3c|zkC^c*d0T^vnUvXz S!b)RHAM>FfvRZ9*J!1RGaLMQZ literal 0 HcmV?d00001 diff --git a/packages/extraction/tests/fixtures/pages.pdf b/packages/extraction/tests/fixtures/pages.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1e7acc2462718b0b8da896326f6a15871d0d0334 GIT binary patch literal 950 zcmcgr!A|2a5WUY=%%u_>z;V(fL_!=2M7!EuZI$MRIGAQqf=XODE-L&ahy8=)NAOq; zMCpYSmJ-?HnK$pvcqa3!{6wBcF!}%X`VS-qsP7)&{2avG=xVzpgGZa^O;sPm#RX7X z@pU+GE1x%nVy;%yfwy=B(NXQwALBRAN zAlI!0vDn^OPk?Di5Hr;g-=O$G>nF0!QVmd;PA~71H;95dy){dhmxcRyzmdp!Jr5^d z{GqB*Gre=dg44i%i3~B#LcC?-hFH9haFuASJ9e1#2>aUcx##ek+LgV>X~G;kYe+r9 ztVEGZbhu@WsY4F~EGs<98`D|bxay##oju0#0k2E$$Ubn7&&BTXzf0rUNF$$n4!y?o z3mO-+)NS|K{)x DfClz) literal 0 HcmV?d00001 diff --git a/packages/extraction/tests/isolation.mjs b/packages/extraction/tests/isolation.mjs new file mode 100644 index 00000000..d44ef800 --- /dev/null +++ b/packages/extraction/tests/isolation.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile, rm, readdir } from 'node:fs/promises'; +import { once } from 'node:events'; +import { RestrictedBackend } from '/opt/extraction/dist/backend.js'; + +await mkdir('/jobs/other'); +await writeFile('/jobs/other/secret', 'cross-job-canary'); +await writeFile('/socket/secret', 'transport-canary'); +const job = await mkdtemp('/jobs/job-'); +const child = spawn( + '/opt/extraction/guard', + [job, '/usr/local/bin/python3.13', '/tests/isolation.py', 'probe'], + { + env: { EXTRACTION_SECRET_CANARY: 'private-canary' }, + stdio: ['ignore', 'pipe', 'pipe'], + } +); +let output = '', + errors = ''; +child.stdout.on('data', bytes => { + output += bytes; +}); +child.stderr.on('data', bytes => { + errors += bytes; +}); +const [code] = await once(child, 'close'); +assert.equal(code, 0, errors); +assert.equal(output.trim(), 'ISOLATED'); +await rm(job, { recursive: true }); +const runtime = { + jobs: '/jobs', + guard: '/opt/extraction/guard', + python: '/usr/local/bin/python3.13', + worker: '/tests/isolation.py', +}; +const backend = new RestrictedBackend(runtime); +for (const abort of ['immediate', 'running']) { + const job = await backend.create(); + const signal = new AbortController(); + const pending = backend.run(job, 'hang', 'a'.repeat(64), signal.signal); + if (abort === 'running') + await new Promise(resolve => setTimeout(resolve, 100)); + signal.abort(new Error('CANCELLED')); + await assert.rejects(pending); + await backend.cleanup(job); +} +const job2 = await backend.create(); +await assert.rejects( + backend.run(job2, 'hang', 'a'.repeat(64), AbortSignal.timeout(100)) +); +await backend.cleanup(job2); +await rm('/jobs/other', { recursive: true }); +await rm('/socket/secret'); +assert.deepEqual(await readdir('/jobs'), []); +const missing = new RestrictedBackend({ ...runtime, guard: '/nonexistent' }); +const job3 = await missing.create(); +await assert.rejects( + missing.run(job3, 'pdf', 'a'.repeat(64), AbortSignal.timeout(1000)) +); +await missing.cleanup(job3); +console.log( + 'Container isolation: cross-job/credential paths, sockets, fork, signals, limits, startup/running cancellation, deadline and missing launcher passed' +); diff --git a/packages/extraction/tests/isolation.py b/packages/extraction/tests/isolation.py new file mode 100644 index 00000000..e9124319 --- /dev/null +++ b/packages/extraction/tests/isolation.py @@ -0,0 +1,36 @@ +import os +import resource +import signal +import socket +import sys + +if sys.argv[1] == 'hang': + # Exercise supervisor cancellation/deadline termination, not a cooperative parser. + while True: + pass + +for path, mode in [('/jobs/other/secret', 'rb'), ('/jobs/other/secret', 'wb'), + ('/socket/secret', 'rb'), ('/proc/self/environ', 'rb'), ('/etc/passwd', 'rb')]: + try: + with open(path, mode) as file: + if mode == 'rb': + file.read() + except PermissionError: + pass + else: + raise RuntimeError('Cross-boundary file access') +for operation in [lambda: socket.socket(), lambda: socket.socket(socket.AF_UNIX), + lambda: os.fork(), lambda: os.kill(os.getppid(), 0), + lambda: os.chmod('/jobs/other/secret', 0o777)]: + try: + operation() + except PermissionError: + pass + else: + raise RuntimeError('Forbidden syscall') +assert 'EXTRACTION_SECRET_CANARY' not in os.environ +assert resource.getrlimit(resource.RLIMIT_AS) == (512 * 1024 * 1024,) * 2 +assert resource.getrlimit(resource.RLIMIT_CPU) == (8, 8) +assert resource.getrlimit(resource.RLIMIT_FSIZE) == (4 * 1024 * 1024,) * 2 +assert resource.getrlimit(resource.RLIMIT_NOFILE) == (64, 64) +print('ISOLATED', flush=True) diff --git a/packages/extraction/tsconfig.json b/packages/extraction/tsconfig.json new file mode 100644 index 00000000..aff02060 --- /dev/null +++ b/packages/extraction/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py new file mode 100644 index 00000000..5b279842 --- /dev/null +++ b/packages/extraction/worker/extract.py @@ -0,0 +1,217 @@ +"""Request-local parser. Only the supervisor can choose its mode and files.""" +import hashlib +import io +import json +import os +from pathlib import PurePosixPath +import socket +import sys +import zipfile +import zlib + +INPUT_BYTES = 10 * 1024 * 1024 +TEXT_BYTES = 1024 * 1024 +RESULT_BYTES = 3 * 1024 * 1024 +EXPANDED_BYTES = 32 * 1024 * 1024 +ENTRY_BYTES = 8 * 1024 * 1024 +SEGMENTS = 128 + + +class Rejected(Exception): + def __init__(self, code): + self.code = code + + +class Text: + def __init__(self): + self.size = 0 + self.parts = [] + + def add(self, value): + size = len(value.encode("utf-8", errors="strict")) + if self.size + size > TEXT_BYTES: + raise Rejected("OUTPUT_LIMIT") + self.size += size + self.parts.append(value) + + def value(self): + return "".join(self.parts) + + +def pdf_segments(data): + import pypdf + from pypdf import filters + + used = 0 + original = filters.decode_stream_data + + def inflate(value): + # Strict bounded decoding, without pypdf's recovery/fallback paths. + cap = min(ENTRY_BYTES, EXPANDED_BYTES - used) + decoder = zlib.decompressobj() + result = decoder.decompress(value, cap + 1) + if len(result) > cap or decoder.unconsumed_tail: + raise Rejected("DECOMPRESSION_LIMIT") + if not decoder.eof: + raise Rejected("INVALID_DOCUMENT") + return result + + def decode(stream): + nonlocal used + names = stream.get("/Filter", []) + if not isinstance(names, list): + names = [names] + if len(names) > 1 or any(str(name) not in { + "/FlateDecode", "/Fl", "/ASCIIHexDecode", "/AHx", "/ASCII85Decode", "/A85" + } for name in names): + raise Rejected("UNSUPPORTED_ENCODING") + value = original(stream) + used += len(value) + if len(value) > ENTRY_BYTES or used > EXPANDED_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + return value + + filters.decompress = inflate + filters.decode_stream_data = decode + reader = pypdf.PdfReader(io.BytesIO(data), strict=True) + if reader.is_encrypted: + raise Rejected("ENCRYPTED_DOCUMENT") + if len(reader.pages) > SEGMENTS: + raise Rejected("STRUCTURE_LIMIT") + result = [] + text = Text() + for index, page in enumerate(reader.pages): + value = page.extract_text() or "" + text.add(value) + result.append({"kind": "page", "index": index + 1, "text": value}) + return result, text.size + + +def safe_docx(data): + total = 0 + names = set() + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as archive, zipfile.ZipFile(output, "w") as clean: + if len(archive.infolist()) > 512: + raise Rejected("STRUCTURE_LIMIT") + for entry in archive.infolist(): + name = entry.filename + path = PurePosixPath(name) + if (name in names or path.is_absolute() or ".." in path.parts or "\\" in name + or "\x00" in name or (entry.external_attr >> 16) & 0o170000 == 0o120000 + or entry.flag_bits & 1 or entry.compress_type not in (0, 8)): + raise Rejected("INVALID_DOCUMENT") + names.add(name) + if entry.file_size > ENTRY_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + chunks = [] + size = 0 + with archive.open(entry) as source: + while chunk := source.read(64 * 1024): + size += len(chunk) + total += len(chunk) + if size > ENTRY_BYTES or total > EXPANDED_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + chunks.append(chunk) + value = b"".join(chunks) + if name.endswith((".xml", ".rels")): + xml = value.decode("utf-8", errors="strict") + if "\x00" in xml or " INPUT_BYTES: + raise Rejected("INPUT_LIMIT") + mode = sys.argv[1] + if mode == "pdf": + if not data.startswith(b"%PDF-"): + raise Rejected("INVALID_DOCUMENT") + segments, size = pdf_segments(data) + elif mode == "docx": + if not data.startswith(b"PK\x03\x04"): + raise Rejected("INVALID_DOCUMENT") + segments, size = docx_segments(data) + else: + raise Rejected("UNSUPPORTED_FORMAT") + if not any(item["text"].strip() for item in segments): + raise Rejected("EMPTY_OUTPUT") + result = json.dumps({"version": 1, "operation": "document.extract-text", "format": mode, + "inputSha256": hashlib.sha256(data).hexdigest(), "textBytes": size, + "segments": segments}, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(result) > RESULT_BYTES: + raise Rejected("OUTPUT_LIMIT") + with open("result", "xb") as target: + target.write(result) + + +if __name__ == "__main__": + try: + main() + except Rejected as error: + print(error.code, flush=True) + sys.exit(2) + except MemoryError: + print("RESOURCE_LIMIT", flush=True) + sys.exit(2) + except Exception: + print("INVALID_DOCUMENT", flush=True) + sys.exit(2) diff --git a/packages/extraction/worker/requirements.txt b/packages/extraction/worker/requirements.txt new file mode 100644 index 00000000..25ad0ede --- /dev/null +++ b/packages/extraction/worker/requirements.txt @@ -0,0 +1,4 @@ +pypdf==6.19.0 +python-docx==1.2.0 +lxml==6.0.2 +typing_extensions==4.15.0 From e5b4fef516986e1679d89a11823b3eae873d6428 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 2/8] fix: Restrict Worker Control and Preserve Nested Document Text --- packages/extraction/README.md | 5 +- packages/extraction/compose.yaml | 2 +- packages/extraction/native/guard.c | 8 +++ packages/extraction/tests/adversarial.py | 22 +++++++ .../extraction/tests/container-client.mjs | 19 +++++- packages/extraction/tests/container.sh | 8 ++- packages/extraction/tests/isolation.py | 32 +++++++++- packages/extraction/worker/extract.py | 58 ++++++++++++++----- 8 files changed, 129 insertions(+), 25 deletions(-) diff --git a/packages/extraction/README.md b/packages/extraction/README.md index 3ce21769..1d53a645 100644 --- a/packages/extraction/README.md +++ b/packages/extraction/README.md @@ -81,9 +81,10 @@ deadline. A disconnected or stalled upload may close without a response. There i - **PDF:** pypdf, one segment per page, including empty pages. Text only, without OCR, image decoding, attachment extraction, or script execution. Encrypted files are rejected. Compressed text streams support a single Flate, ASCIIHex, or ASCII85 filter. Other filter - combinations are deliberately rejected. Malformed PDFs are not repaired. + combinations are deliberately rejected. Malformed PDFs are not repaired. The decoded-byte budget also counts intermediate + inflation buffers when predictors transform them. - **DOCX:** python-docx/lxml, one document-body segment. Paragraphs and table rows remain - ordered; cells are tab-separated. Headers, footers, comments, text boxes, tracked revisions, + ordered; cells are tab-separated, including nested tables (at most 32 levels). Headers, footers, comments, text boxes, tracked revisions, embedded files, and pagination are not extracted. ZIP entries are measured while reading, then reconstructed into an uncompressed archive before the native XML parser sees them. Duplicate names, unsafe paths, symlinks, encryption, non-UTF-8 XML and DTDs are rejected. diff --git a/packages/extraction/compose.yaml b/packages/extraction/compose.yaml index f5288349..f6bbf9f4 100644 --- a/packages/extraction/compose.yaml +++ b/packages/extraction/compose.yaml @@ -1,6 +1,6 @@ services: extraction: - image: librechat-extraction:local + image: ${EXTRACTION_IMAGE:-librechat-extraction:local} build: context: ../.. dockerfile: packages/extraction/Dockerfile diff --git a/packages/extraction/native/guard.c b/packages/extraction/native/guard.c index 945c7883..7dc8f379 100644 --- a/packages/extraction/native/guard.c +++ b/packages/extraction/native/guard.c @@ -65,6 +65,14 @@ static void restrict_syscalls(void) { #endif DENY(__NR_ptrace), DENY(__NR_process_vm_readv), DENY(__NR_process_vm_writev), DENY(__NR_kill), DENY(__NR_tkill), DENY(__NR_tgkill), DENY(__NR_pidfd_send_signal), + DENY(__NR_rt_sigqueueinfo), DENY(__NR_rt_tgsigqueueinfo), DENY(__NR_ioctl), + /* Async descriptor ownership can cause kernel-delivered signals. */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fcntl, 0, 7), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), + DENY(F_SETOWN), DENY(F_SETOWN_EX), DENY(F_SETSIG), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + DENY(__NR_mq_open), DENY(__NR_mq_unlink), DENY(__NR_mq_timedsend), + DENY(__NR_mq_timedreceive), DENY(__NR_mq_notify), DENY(__NR_mq_getsetattr), DENY(__NR_mount), DENY(__NR_umount2), DENY(__NR_pivot_root), DENY(__NR_chroot), DENY(__NR_unshare), DENY(__NR_setns), DENY(__NR_bpf), DENY(__NR_userfaultfd), DENY(__NR_io_uring_setup), DENY(__NR_perf_event_open), diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index fa5481bb..0f0bb9ce 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -42,3 +42,25 @@ writer.add_blank_page(width=10, height=10) writer.write(root / 'too-many-pages.pdf') print('Adversarial documents generated') + +for delta, name in [(0, 'raw-exact.pdf'), (1, 'raw-limit.pdf')]: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + stream = writer.pages[0]['/Contents'].get_object() + value = stream.get_data() + stream.set_data(b' ' * (8 * 1024 * 1024 - len(value) + delta) + value) + writer.write(root / name) + +document = Document() +outer = document.add_table(rows=1, cols=1) +cell = outer.cell(0, 0) +cell.paragraphs[0].text = 'Before nested table' +nested = cell.add_table(rows=1, cols=2) +nested.cell(0, 0).text = 'Nested left' +nested.cell(0, 1).text = 'Nested right' +cell.add_paragraph('After nested table') +document.save(root / 'nested.docx') +document = Document() +outer = document.add_table(rows=1, cols=1) +outer.cell(0, 0).add_table(rows=1, cols=1).cell(0, 0).text = 'Only nested text' +document.save(root / 'nested-only.docx') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index 85dcea91..e1538c0e 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -66,11 +66,24 @@ assert.deepEqual(docx.value.segments, [ text: 'First paragraph\nLeft cell\tRight cell\nLast paragraph\n', }, ]); +const nested = await send('docx', readFileSync(`${fixtures}/nested.docx`)); +assert.equal(nested.status, 200); +assert.match( + nested.value.segments[0].text, + /Before nested table\nNested left\tNested right[\n]+After nested table/ +); +const nestedOnly = await send( + 'docx', + readFileSync(`${fixtures}/nested-only.docx`) +); +assert.equal(nestedOnly.status, 200); +assert.match(nestedOnly.value.segments[0].text, /Only nested text/); for (const [name, format, code] of [ ['empty.pdf', 'pdf', 'EMPTY_OUTPUT'], ['empty.docx', 'docx', 'EMPTY_OUTPUT'], ['inflate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], ['aggregate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['raw-limit.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], ['aggregate.docx', 'docx', 'DECOMPRESSION_LIMIT'], ['bad.docx', 'docx', 'INVALID_DOCUMENT'], ['entries.docx', 'docx', 'STRUCTURE_LIMIT'], @@ -90,6 +103,10 @@ for (const [name, format, code] of [ `${name}: ${JSON.stringify(response)}` ); } +assert.equal( + (await send('pdf', readFileSync(`${fixtures}/raw-exact.pdf`))).status, + 200 +); const exact = await send('docx', readFileSync(`${fixtures}/exact.docx`)); assert.equal(exact.status, 200); assert.equal(exact.value.textBytes, 1024 * 1024); @@ -118,5 +135,5 @@ pages.copy(padded); assert.equal((await send('pdf', padded)).status, 200); assert.equal((await send('pdf', Buffer.alloc(padded.length + 1))).status, 413); console.log( - 'Container endpoint: PDF/DOCX success, structure, input limits and 15 adversarial fixtures passed' + 'Container endpoint: PDF/DOCX success, structure, input limits and 16 adversarial fixtures passed' ); diff --git a/packages/extraction/tests/container.sh b/packages/extraction/tests/container.sh index 6f33b89e..839670c5 100755 --- a/packages/extraction/tests/container.sh +++ b/packages/extraction/tests/container.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")/../../.." -project="extraction-test-$$" -image="librechat-extraction-test:$$" +run_id=$(node -e 'console.log(require("node:crypto").randomUUID())') +project="extraction-test-$run_id" +image="librechat-extraction-test:$run_id" +export EXTRACTION_IMAGE="librechat-extraction:$run_id" compose=(docker compose -p "$project" -f packages/extraction/compose.yaml) cleanup() { "${compose[@]}" down -v --remove-orphans >/dev/null - docker image rm "$image" >/dev/null 2>&1 || true + docker image rm "$image" "$EXTRACTION_IMAGE" >/dev/null 2>&1 || true } trap cleanup EXIT "${compose[@]}" build extraction diff --git a/packages/extraction/tests/isolation.py b/packages/extraction/tests/isolation.py index e9124319..39314a98 100644 --- a/packages/extraction/tests/isolation.py +++ b/packages/extraction/tests/isolation.py @@ -1,3 +1,6 @@ +import ctypes +import errno +import fcntl import os import resource import signal @@ -21,7 +24,8 @@ raise RuntimeError('Cross-boundary file access') for operation in [lambda: socket.socket(), lambda: socket.socket(socket.AF_UNIX), lambda: os.fork(), lambda: os.kill(os.getppid(), 0), - lambda: os.chmod('/jobs/other/secret', 0o777)]: + lambda: os.chmod('/jobs/other/secret', 0o777), + lambda: fcntl.fcntl(1, fcntl.F_SETOWN, os.getppid())]: try: operation() except PermissionError: @@ -33,4 +37,28 @@ assert resource.getrlimit(resource.RLIMIT_CPU) == (8, 8) assert resource.getrlimit(resource.RLIMIT_FSIZE) == (4 * 1024 * 1024,) * 2 assert resource.getrlimit(resource.RLIMIT_NOFILE) == (64, 64) -print('ISOLATED', flush=True) + + +# Harmless signal-zero probes distinguish filtering from ordinary permissions. +class Sigval(ctypes.Union): + _fields_ = [("integer", ctypes.c_int), ("pointer", ctypes.c_void_p)] +libc = ctypes.CDLL(None, use_errno=True) +libc.sigqueue.argtypes = [ctypes.c_int, ctypes.c_int, Sigval] +assert libc.sigqueue(os.getppid(), 0, Sigval(integer=0)) == -1 +assert ctypes.get_errno() == errno.EPERM +number = {"x86_64": 297, "aarch64": 240}[os.uname().machine] +info = (ctypes.c_int * 32)() +info[2] = -1 # SI_QUEUE, with signal zero: never delivers a signal. +assert libc.syscall(number, os.getppid(), os.getppid(), 0, ctypes.byref(info)) == -1 +assert ctypes.get_errno() == errno.EPERM + + + +try: + fcntl.ioctl(1, 0, 0) +except PermissionError: + pass +else: + raise RuntimeError('Forbidden ioctl') + +print("ISOLATED", flush=True) diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 5b279842..f892c2cf 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -41,11 +41,15 @@ def value(self): def pdf_segments(data): import pypdf from pypdf import filters + from pypdf.generic import DecodedStreamObject used = 0 + credited = {} original = filters.decode_stream_data + original_data = DecodedStreamObject.get_data def inflate(value): + nonlocal used # Strict bounded decoding, without pypdf's recovery/fallback paths. cap = min(ENTRY_BYTES, EXPANDED_BYTES - used) decoder = zlib.decompressobj() @@ -54,10 +58,22 @@ def inflate(value): raise Rejected("DECOMPRESSION_LIMIT") if not decoder.eof: raise Rejected("INVALID_DOCUMENT") + used += len(result) + credited[id(result)] = result return result - def decode(stream): + def decoded_data(stream): nonlocal used + value = original_data(stream) + if not getattr(stream, "_extraction_counted", False): + if credited.get(id(value)) is not value: + used += len(value) + if len(value) > ENTRY_BYTES or used > EXPANDED_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + stream._extraction_counted = True + return value + + def decode(stream): names = stream.get("/Filter", []) if not isinstance(names, list): names = [names] @@ -65,12 +81,9 @@ def decode(stream): "/FlateDecode", "/Fl", "/ASCIIHexDecode", "/AHx", "/ASCII85Decode", "/A85" } for name in names): raise Rejected("UNSUPPORTED_ENCODING") - value = original(stream) - used += len(value) - if len(value) > ENTRY_BYTES or used > EXPANDED_BYTES: - raise Rejected("DECOMPRESSION_LIMIT") - return value + return original(stream) + DecodedStreamObject.get_data = decoded_data filters.decompress = inflate filters.decode_stream_data = decode reader = pypdf.PdfReader(io.BytesIO(data), strict=True) @@ -129,17 +142,30 @@ def docx_segments(data): document = Document(io.BytesIO(safe_docx(data))) text = Text() - for block in document.iter_inner_content(): - if isinstance(block, Table): - for row in block.rows: - for index, cell in enumerate(row.cells): - if index: - text.add("\t") - text.add(cell.text) + def blocks(container, depth=0, cell=False): + if depth > 32: + raise Rejected("STRUCTURE_LIMIT") + first = True + for block in container.iter_inner_content(): + if cell and not first: text.add("\n") - else: - text.add(block.text) - text.add("\n") + first = False + if isinstance(block, Table): + for row_index, row in enumerate(block.rows): + if row_index: + text.add("\n") + for index, child in enumerate(row.cells): + if index: + text.add("\t") + blocks(child, depth + 1, cell=True) + if not cell: + text.add("\n") + else: + text.add(block.text) + if not cell: + text.add("\n") + + blocks(document) return [{"kind": "document", "index": 1, "text": text.value()}], text.size From 8fd61b244e856768e1b7a5adf753d335418b664a Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 3/8] fix: Preserve Parser Policy Failures and Startup Restrictions --- packages/extraction/README.md | 3 +- packages/extraction/native/guard.c | 9 +- packages/extraction/tests/adversarial.py | 49 +++++++++++ .../extraction/tests/container-client.mjs | 8 +- packages/extraction/tests/isolation.py | 11 ++- packages/extraction/worker/extract.py | 85 ++++++++++++++++--- 6 files changed, 149 insertions(+), 16 deletions(-) diff --git a/packages/extraction/README.md b/packages/extraction/README.md index 1d53a645..3fdda7d7 100644 --- a/packages/extraction/README.md +++ b/packages/extraction/README.md @@ -81,7 +81,8 @@ deadline. A disconnected or stalled upload may close without a response. There i - **PDF:** pypdf, one segment per page, including empty pages. Text only, without OCR, image decoding, attachment extraction, or script execution. Encrypted files are rejected. Compressed text streams support a single Flate, ASCIIHex, or ASCII85 filter. Other filter - combinations are deliberately rejected. Malformed PDFs are not repaired. The decoded-byte budget also counts intermediate + combinations are deliberately rejected. Malformed PDFs are not repaired. Recovery warnings reject the document rather than + returning partial text. The decoded-byte budget also counts intermediate inflation buffers when predictors transform them. - **DOCX:** python-docx/lxml, one document-body segment. Paragraphs and table rows remain ordered; cells are tab-separated, including nested tables (at most 32 levels). Headers, footers, comments, text boxes, tracked revisions, diff --git a/packages/extraction/native/guard.c b/packages/extraction/native/guard.c index 7dc8f379..ee1d2cbc 100644 --- a/packages/extraction/native/guard.c +++ b/packages/extraction/native/guard.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -65,7 +66,13 @@ static void restrict_syscalls(void) { #endif DENY(__NR_ptrace), DENY(__NR_process_vm_readv), DENY(__NR_process_vm_writev), DENY(__NR_kill), DENY(__NR_tkill), DENY(__NR_tgkill), DENY(__NR_pidfd_send_signal), - DENY(__NR_rt_sigqueueinfo), DENY(__NR_rt_tgsigqueueinfo), DENY(__NR_ioctl), + DENY(__NR_rt_sigqueueinfo), DENY(__NR_rt_tgsigqueueinfo), + /* CPython marks opened files close-on-exec through FIOCLEX. */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_ioctl, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, FIOCLEX, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* Async descriptor ownership can cause kernel-delivered signals. */ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fcntl, 0, 7), BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index 0f0bb9ce..7a08adb9 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -64,3 +64,52 @@ outer = document.add_table(rows=1, cols=1) outer.cell(0, 0).add_table(rows=1, cols=1).cell(0, 0).text = 'Only nested text' document.save(root / 'nested-only.docx') + +from pypdf.generic import ( + ArrayObject, DecodedStreamObject, DictionaryObject, EncodedStreamObject, + NameObject, NumberObject, +) +for name, mode in [('form-limit.pdf', 'limit'), ('form-filter.pdf', 'filter'), ('form-valid.pdf', 'valid')]: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + page = writer.pages[0] + if mode == 'filter': + form = EncodedStreamObject() + form._data = b'x' + form[NameObject('/Filter')] = NameObject('/LZWDecode') + else: + form = DecodedStreamObject() + form.set_data(b' ' * (8 * 1024 * 1024 + 1) if mode == 'limit' + else b'BT /F1 12 Tf 72 700 Td (Form content) Tj ET') + form = form.flate_encode() + form[NameObject('/Type')] = NameObject('/XObject') + form[NameObject('/Subtype')] = NameObject('/Form') + form[NameObject('/BBox')] = ArrayObject([NumberObject(v) for v in [0, 0, 612, 792]]) + form[NameObject('/Resources')] = DictionaryObject({NameObject('/Font'): page['/Resources']['/Font']}) + page['/Resources'][NameObject('/XObject')] = DictionaryObject({NameObject('/Form'): writer._add_object(form)}) + stream = page['/Contents'].get_object() + stream.set_data(stream.get_data() + b'\n/Form Do') + writer.write(root / name) + +# A valid XML prefix with forged size AND CRC must not hide trailing inflation. +with zipfile.ZipFile(root / 'body.docx') as source, zipfile.ZipFile(root / 'forged-bomb.docx', 'w', compression=zipfile.ZIP_DEFLATED) as target: + prefix = source.read('word/document.xml') + for entry in source.infolist(): + value = source.read(entry) + if entry.filename == 'word/document.xml': + value += b' ' * (9 * 1024 * 1024) + target.writestr(entry.filename, value) +value = bytearray((root / 'forged-bomb.docx').read_bytes()) +position = 0 +while True: + position = value.find(b'PK\x01\x02', position) + if position < 0: + raise RuntimeError('Missing central entry') + length = struct.unpack_from(' cap or decoder.unconsumed_tail: - raise Rejected("DECOMPRESSION_LIMIT") - if not decoder.eof: - raise Rejected("INVALID_DOCUMENT") + reject("DECOMPRESSION_LIMIT") + if not decoder.eof or decoder.unused_data: + reject("INVALID_DOCUMENT") used += len(result) credited[id(result)] = result return result @@ -69,7 +94,7 @@ def decoded_data(stream): if credited.get(id(value)) is not value: used += len(value) if len(value) > ENTRY_BYTES or used > EXPANDED_BYTES: - raise Rejected("DECOMPRESSION_LIMIT") + reject("DECOMPRESSION_LIMIT") stream._extraction_counted = True return value @@ -80,13 +105,21 @@ def decode(stream): if len(names) > 1 or any(str(name) not in { "/FlateDecode", "/Fl", "/ASCIIHexDecode", "/AHx", "/ASCII85Decode", "/A85" } for name in names): - raise Rejected("UNSUPPORTED_ENCODING") - return original(stream) + reject("UNSUPPORTED_ENCODING") + try: + return original(stream) + except Rejected: + raise + except MemoryError: + reject("RESOURCE_LIMIT") + except Exception: + reject("INVALID_DOCUMENT") DecodedStreamObject.get_data = decoded_data filters.decompress = inflate filters.decode_stream_data = decode reader = pypdf.PdfReader(io.BytesIO(data), strict=True) + check_failure() if reader.is_encrypted: raise Rejected("ENCRYPTED_DOCUMENT") if len(reader.pages) > SEGMENTS: @@ -95,8 +128,10 @@ def decode(stream): text = Text() for index, page in enumerate(reader.pages): value = page.extract_text() or "" + check_failure() text.add(value) result.append({"kind": "page", "index": index + 1, "text": value}) + check_failure() return result, text.size @@ -109,6 +144,8 @@ def safe_docx(data): raise Rejected("STRUCTURE_LIMIT") for entry in archive.infolist(): name = entry.filename + if entry.orig_filename != name: + raise Rejected("INVALID_DOCUMENT") path = PurePosixPath(name) if (name in names or path.is_absolute() or ".." in path.parts or "\\" in name or "\x00" in name or (entry.external_attr >> 16) & 0o170000 == 0o120000 @@ -117,17 +154,41 @@ def safe_docx(data): names.add(name) if entry.file_size > ENTRY_BYTES: raise Rejected("DECOMPRESSION_LIMIT") - chunks = [] - size = 0 - with archive.open(entry) as source: - while chunk := source.read(64 * 1024): + # Let ZipFile validate local names, flags and entry overlap, but do + # not use ZipExtFile.read(): it trims data to untrusted file_size. + with archive.open(entry): + offset = entry.header_offset + if offset < 0 or offset + 30 > len(data): + raise Rejected("INVALID_DOCUMENT") + method = struct.unpack_from(" len(data): + raise Rejected("INVALID_DOCUMENT") + decoder = zlib.decompressobj(-zlib.MAX_WBITS) if entry.compress_type == 8 else None + chunks = [] + size = 0 + crc = 0 + for position in range(start, end, 64 * 1024): + chunk = data[position:min(position + 64 * 1024, end)] + if decoder: + cap = min(ENTRY_BYTES - size, EXPANDED_BYTES - total) + chunk = decoder.decompress(chunk, cap + 1) size += len(chunk) total += len(chunk) if size > ENTRY_BYTES or total > EXPANDED_BYTES: raise Rejected("DECOMPRESSION_LIMIT") + crc = zlib.crc32(chunk, crc) chunks.append(chunk) + if decoder and (not decoder.eof or decoder.unused_data or decoder.unconsumed_tail): + raise Rejected("INVALID_DOCUMENT") + if size != entry.file_size or crc != entry.CRC: + raise Rejected("INVALID_DOCUMENT") value = b"".join(chunks) - if name.endswith((".xml", ".rels")): + if name.lower().endswith((".xml", ".rels")): xml = value.decode("utf-8", errors="strict") if "\x00" in xml or " Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 4/8] fix: Reject Unsupported DOCX Content and Validate OPC XML Parts --- README.md | 4 - packages/extraction/README.md | 171 ------------------ packages/extraction/package.json | 2 +- packages/extraction/src/backend.ts | 1 + packages/extraction/src/protocol.ts | 1 + packages/extraction/src/server.test.ts | 18 ++ packages/extraction/src/server.ts | 3 + packages/extraction/tests/adversarial.py | 76 ++++++++ .../extraction/tests/container-client.mjs | 22 ++- packages/extraction/worker/extract.py | 60 +++++- 10 files changed, 174 insertions(+), 184 deletions(-) delete mode 100644 packages/extraction/README.md diff --git a/README.md b/README.md index 2c99051b..b746e3c0 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,3 @@ session-management routes stay unauthenticated for backwards compatibility. - Worker: `GET /health` and `GET /ready` - File Server: `GET /health` and `GET /ready` - Tool Call Server: `GET /health` - -## Standalone File Extraction - -[File extraction service](packages/extraction/README.md): a separate, self-hostable companion container for bounded PDF/DOCX text extraction. It does not require or modify the Code Interpreter API or `/exec`. diff --git a/packages/extraction/README.md b/packages/extraction/README.md deleted file mode 100644 index 3fdda7d7..00000000 --- a/packages/extraction/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# File extraction service - -Standalone companion container for fixed `document.extract-text` operations. It does not -start or call the Code Interpreter API, `/exec`, Redis, tool calling, or conversation storage. -LibreChat and RAG API client migrations are not included. - -## Deploy - -Requires a Linux Docker host with Landlock ABI 3 or newer (normally Linux 6.2+), seccomp, -and Docker Compose. Docker Desktop support depends on its Linux VM kernel and is not assumed. - -```sh -docker compose -f packages/extraction/compose.yaml up --build -d --wait -``` - -Only a Unix socket is exposed. Share the `extraction-socket` named volume with the authorized -caller, mounted read-only at its chosen socket directory. The caller must be able to use group -10001; the socket is mode 0660, its directory 0770. Do not publish a TCP port or mount the -Docker socket, application storage, secrets, or cloud/database credentials into this service. -Socket access authorizes use of the processor, not access to any user's files. - -The processor starts only after the restricted worker imports its dependencies and verifies -filesystem, socket, and process restrictions. Unsupported isolation or missing dependencies -prevent startup. A cleanup failure makes subsequent admission and readiness fail until restart. -No unsandboxed or local-parser fallback exists. - -## Protocol - -`GET /v1/capabilities` reports the protocol version, fixed operation, supported formats and caps. - -`POST /v1/extract-text` accepts document bytes directly, with these required headers: - -```text -Content-Type: application/octet-stream -Content-Length: -X-Extraction-Version: 1 -X-Extraction-Format: pdf | docx -``` - -Unknown extraction parameters, query parameters, chunked uploads, duplicate control headers, -and unsupported formats are rejected before parser execution. No scripts, paths, URLs, -passwords, parser options, or runtime selection are accepted. - -Example from a caller with the socket mounted at `/run/extraction`: - -```sh -curl --unix-socket /run/extraction/extraction.sock \ - -H 'Content-Type: application/octet-stream' \ - -H 'X-Extraction-Version: 1' -H 'X-Extraction-Format: pdf' \ - --data-binary @document.pdf http://localhost/v1/extract-text -``` - -The response is bounded UTF-8 JSON, directly returned without base64 or upload/download staging: - -```json -{ - "version": 1, - "operation": "document.extract-text", - "format": "pdf", - "inputSha256": "", - "textBytes": 31, - "segments": [ - { "kind": "page", "index": 1, "text": "First page text" }, - { "kind": "page", "index": 2, "text": "Second page text" } - ] -} -``` - -The supervisor validates version, operation, format, digest, segment kinds/order, exact UTF-8 -text byte count, and result size after the parser closes. Scratch cleanup completes before -success is sent. Admission remains held until response delivery finishes or the socket closes. - -Errors are `{ "error": { "code": "..." } }`, never raw parser diagnostics or submitted content. -HTTP 400 covers invalid requests; 413 covers input/decompression/structure/output caps; -422 covers malformed, encrypted, empty, unsupported-encoding, or resource-limited documents; -429 is immediate admission rejection; 503 is unavailable isolation/backend; 504 is a processing -deadline. A disconnected or stalled upload may close without a response. There is no queue. - -## Initial formats - -- **PDF:** pypdf, one segment per page, including empty pages. Text only, without OCR, - image decoding, attachment extraction, or script execution. Encrypted files are rejected. - Compressed text streams support a single Flate, ASCIIHex, or ASCII85 filter. Other filter - combinations are deliberately rejected. Malformed PDFs are not repaired. Recovery warnings reject the document rather than - returning partial text. The decoded-byte budget also counts intermediate - inflation buffers when predictors transform them. -- **DOCX:** python-docx/lxml, one document-body segment. Paragraphs and table rows remain - ordered; cells are tab-separated, including nested tables (at most 32 levels). Headers, footers, comments, text boxes, tracked revisions, - embedded files, and pagination are not extracted. ZIP entries are measured while reading, - then reconstructed into an uncompressed archive before the native XML parser sees them. - Duplicate names, unsafe paths, symlinks, encryption, non-UTF-8 XML and DTDs are rejected. - -Spreadsheet formats are not advertised. A later spreadsheet slice must return ordered sheet -segments, not flatten them into one string. Consumers retain page/sheet metadata and choose -any flattening themselves. Raw text/Markdown semantics, OCR and office previews are separate -operations, not fallback behavior of this endpoint. - -## Limits and lifecycle - -| Bound | Default | -| ------------------------------------------------ | --------------------: | -| Input | 10 MiB | -| Aggregate decoded PDF/ZIP content | 32 MiB | -| Individual decoded stream/ZIP entry | 8 MiB | -| ZIP entries | 512 | -| PDF pages | 128 | -| Extracted UTF-8 text | 1 MiB | -| Serialized result | 3 MiB | -| Admitted requests, including upload/delivery | 2 | -| HTTP connections | 16 | -| Request wall deadline, including upload/delivery | 10 seconds | -| Worker CPU time / address space | 8 seconds / 512 MiB | -| Worker file size / open descriptors | 4 MiB / 64 | -| Container memory / CPU / PIDs | 768 MiB / 2 CPUs / 32 | -| Shared scratch tmpfs | 96 MiB / 1,024 inodes | - -Bounds are fixed in this initial protocol and advertised by capabilities. The caller must bound -its own transport and apply stricter application-specific limits where needed. Cancellation -kills the parser process group and waits for process close before removing scratch. Container -shutdown closes connections, cancelling active work. Scratch tmpfs disappears with the container. - -## Isolation guarantees and limitations - -The shipped container is unprivileged, non-root, read-only, capability-free, without a network, -and uses Docker's default seccomp profile plus `no-new-privileges`. The Node 24 supervisor loads -no document parser. Every request starts a fresh Python subprocess through a small C launcher: - -- Landlock limits file reads to packaged runtime libraries, the worker, and its own job directory; - writes are limited to that job directory and `/dev/null`. -- An additional seccomp filter denies sockets, process creation, signals to other processes, - ptrace/process-memory access, namespace/mount operations, shared-memory IPC and selected - privileged syscalls. Only Python and its ELF loader receive Landlock execute access. -- The worker inherits no caller environment or descriptors other than standard I/O pipes. - Hard resource limits and parent-death termination are set before parser imports. - -This is **a restricted subprocess inside a shared container, not a disposable container or VM**. -The container and host kernel are shared. Landlock does not hide all filesystem metadata or -provide a separate PID namespace per job. Kernel vulnerabilities and hardware side channels -are outside this boundary. A container-wide OOM can terminate the supervisor and other jobs. -The runtime filesystem is readable, so it must contain no secrets. Do not colocate unrelated -services in the container. A stronger backend can be added later without changing caller policy. - -Authorization, content inspection, identity metadata, chunking, embeddings, ingestion rollback, -and persistence remain with LibreChat/RAG API. Parser output is untrusted content, not trusted -identity or policy. This service neither modifies nor re-enables LibreChat's native LibreOffice path. - -## Verify - -```sh -cd packages/extraction -npm ci --ignore-scripts -npm run typecheck -npm test -npm run test:container -``` - -Unit tests cover request validation, result validation, deadline/cancellation, cleanup failures, -admission and stalled output readers. Container tests build and run the actual Compose recipe, -extract real synthetic PDF/DOCX documents through the endpoint, exercise adversarial archives, -inspect mounts/resources, probe cross-job/credential/network/process access, test worker -termination and restart, and remove their containers/volumes. Fixtures contain no user data. - -Qualification must pass on the deployment host without relaxing capabilities, seccomp, LSMs, -or sysctls. Passing parser tests alone does not qualify a different container/runtime recipe. - -### Qualified runtime - -The shipped recipe passed locally on 2026-10-04 with Docker 29.8.2, Linux -7.0.0-1013-aws (x86_64), Node 24.16.0 and Python 3.13.16. Docker's default -seccomp profile, capability dropping and `no-new-privileges` remained enabled. -Other hosts must run the same qualification before enablement. diff --git a/packages/extraction/package.json b/packages/extraction/package.json index c45ec3e5..5934c126 100644 --- a/packages/extraction/package.json +++ b/packages/extraction/package.json @@ -12,7 +12,7 @@ "test": "npm run build && node --test dist/*.test.js", "start": "node dist/main.js", "test:container": "bash tests/container.sh", - "format:check": "prettier --check src tests/*.mjs *.json compose.yaml README.md ../../.github/workflows/extraction.yml" + "format:check": "prettier --check src tests/*.mjs *.json compose.yaml ../../.github/workflows/extraction.yml" }, "devDependencies": { "@types/node": "24.10.1", diff --git a/packages/extraction/src/backend.ts b/packages/extraction/src/backend.ts index 43ca9bd0..a3831c65 100644 --- a/packages/extraction/src/backend.ts +++ b/packages/extraction/src/backend.ts @@ -30,6 +30,7 @@ const parserErrors = new Set([ 'STRUCTURE_LIMIT', 'ENCRYPTED_DOCUMENT', 'UNSUPPORTED_ENCODING', + 'UNSUPPORTED_CONTENT', 'RESOURCE_LIMIT', ]); export class RestrictedBackend implements Backend { diff --git a/packages/extraction/src/protocol.ts b/packages/extraction/src/protocol.ts index 4ccd22e2..935c902c 100644 --- a/packages/extraction/src/protocol.ts +++ b/packages/extraction/src/protocol.ts @@ -22,6 +22,7 @@ export type ErrorCode = | 'STRUCTURE_LIMIT' | 'ENCRYPTED_DOCUMENT' | 'UNSUPPORTED_ENCODING' + | 'UNSUPPORTED_CONTENT' | 'RESOURCE_LIMIT'; export class ExtractionError extends Error { constructor(readonly code: ErrorCode) { diff --git a/packages/extraction/src/server.test.ts b/packages/extraction/src/server.test.ts index 0a682e7b..60f9bc63 100644 --- a/packages/extraction/src/server.test.ts +++ b/packages/extraction/src/server.test.ts @@ -247,3 +247,21 @@ test('duplicate extraction control headers are rejected before worker creation', await f.close(); } }); + +test('unsupported document content returns a safe failure after cleanup', async () => { + const f = await fixture(); + f.backend.run = async () => { + throw new ExtractionError('UNSUPPORTED_CONTENT'); + }; + try { + const response = await send(f.socketPath); + assert.equal(response.status, 422); + assert.deepEqual(JSON.parse(response.body), { + error: { code: 'UNSUPPORTED_CONTENT' }, + }); + assert.deepEqual(f.events, ['create', 'cleanup']); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } finally { + await f.close(); + } +}); diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts index bfa98646..ed31c1e0 100644 --- a/packages/extraction/src/server.ts +++ b/packages/extraction/src/server.ts @@ -15,6 +15,9 @@ export const capabilities = Object.freeze({ pdf: 'ordered pages, one-based index', docx: 'document body, paragraphs and tables', }, + limitations: { + docx: 'Body only. Content controls, custom XML wrappers, revisions, alternate content, simple fields and text boxes are rejected.', + }, limits: { ...policy, concurrency: 2, diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index 7a08adb9..b87ec0ea 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -113,3 +113,79 @@ break position += 4 (root / 'forged-bomb.docx').write_bytes(value) + +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +import xml.etree.ElementTree as ET + +# Body, cell and inline wrappers are rejected rather than silently omitted. +for name, placement in [('control.docx', 'body'), ('control-cell.docx', 'cell'), + ('control-inline.docx', 'inline'), ('control-only.docx', 'only')]: + document = Document() + if placement != 'only': + document.add_paragraph('Ordinary text must not hide unsupported content') + if placement == 'cell': + paragraph = document.add_table(rows=1, cols=1).cell(0, 0).add_paragraph('Controlled cell text') + element = paragraph._p + elif placement == 'inline': + element = document.add_paragraph('Before ').add_run('Controlled inline text')._r + else: + element = document.add_paragraph('Controlled body text')._p + parent, index = element.getparent(), element.getparent().index(element) + control = OxmlElement('w:sdt') + content = OxmlElement('w:sdtContent') + control.append(content) + content.append(element) + parent.insert(index, control) + document.save(root / name) + +for name, tag in [('custom-wrapper.docx', 'w:customXml'), ('revision.docx', 'w:ins'), + ('simple-field.docx', 'w:fldSimple')]: + document = Document() + document.add_paragraph('Ordinary text') + paragraph = document.add_paragraph('Wrapped text') + element = paragraph.add_run('Hidden field text')._r if tag == 'w:fldSimple' else paragraph._p + parent, index = element.getparent(), element.getparent().index(element) + wrapper = OxmlElement(tag) + wrapper.append(element) + parent.insert(index, wrapper) + document.save(root / name) + +ct = '{http://schemas.openxmlformats.org/package/2006/content-types}' +for name, mapping, payload in [ + ('renamed-valid.docx', 'override', 'valid'), + ('renamed-default.docx', 'default', 'valid'), + ('renamed-case.docx', 'case', 'valid'), + ('renamed-entities.docx', 'override', 'entity'), + ('renamed-default-entities.docx', 'default', 'entity'), + ('renamed-case-entities.docx', 'case', 'entity'), + ('renamed-utf8.docx', 'override', 'utf8'), +]: + with zipfile.ZipFile(root / 'body.docx') as source, zipfile.ZipFile(root / name, 'w', compression=zipfile.ZIP_DEFLATED) as target: + for entry in source.infolist(): + value = source.read(entry) + member = entry.filename + if member == '[Content_Types].xml': + types = ET.fromstring(value) + for item in list(types): + if item.attrib.get('PartName') == '/word/document.xml': + if mapping == 'default': + types.remove(item) + ET.SubElement(types, ct + 'Default', Extension='bin', ContentType=item.attrib['ContentType']) + else: + item.attrib['PartName'] = '/WORD/DOCUMENT.BIN' if mapping == 'case' else '/word/document.bin' + value = ET.tostring(types, encoding='utf-8', xml_declaration=True) + elif member == '_rels/.rels': + value = value.replace(b'word/document.xml', b'word/document.bin') + elif member == 'word/document.xml': + member = 'word/document.bin' + if payload == 'entity': + declaration_end = value.index(b'?>') + 2 + value = value[:declaration_end] + b']>' + value[declaration_end:] + value = value.replace(b'First paragraph', b'&controlled;') + elif payload == 'utf8': + value = value.replace(b'First paragraph', b'\xff') + elif member == 'word/_rels/document.xml.rels': + member = 'word/_rels/document.bin.rels' + target.writestr(member, value) +print('DOCX completeness and content-type fixtures generated') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index d2e7ed79..68ba9078 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -81,7 +81,27 @@ const nestedOnly = await send( ); assert.equal(nestedOnly.status, 200); assert.match(nestedOnly.value.segments[0].text, /Only nested text/); +for (const name of [ + 'renamed-valid.docx', + 'renamed-default.docx', + 'renamed-case.docx', +]) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.deepEqual(response.value.segments, docx.value.segments, name); +} for (const [name, format, code] of [ + ['control.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-cell.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-inline.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-only.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['custom-wrapper.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['revision.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['simple-field.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['renamed-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-default-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-case-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-utf8.docx', 'docx', 'INVALID_DOCUMENT'], ['empty.pdf', 'pdf', 'EMPTY_OUTPUT'], ['empty.docx', 'docx', 'EMPTY_OUTPUT'], ['inflate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], @@ -141,5 +161,5 @@ pages.copy(padded); assert.equal((await send('pdf', padded)).status, 200); assert.equal((await send('pdf', Buffer.alloc(padded.length + 1))).status, 413); console.log( - 'Container endpoint: PDF/DOCX success, structure, input limits and 19 adversarial fixtures passed' + 'Container endpoint: PDF/DOCX success, structure, input limits and 30 adversarial fixtures passed' ); diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 60b5ea40..7446380e 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -8,6 +8,7 @@ import socket import struct import sys +import xml.etree.ElementTree as ET import zipfile import zlib @@ -135,11 +136,46 @@ def decode(stream): return result, text.size +def validate_xml(value): + xml = value.decode("utf-8", errors="strict") + if "\x00" in xml or " 512: raise Rejected("STRUCTURE_LIMIT") for entry in archive.infolist(): @@ -188,11 +224,12 @@ def safe_docx(data): if size != entry.file_size or crc != entry.CRC: raise Rejected("INVALID_DOCUMENT") value = b"".join(chunks) - if name.lower().endswith((".xml", ".rels")): - xml = value.decode("utf-8", errors="strict") - if "\x00" in xml or " 32: From 59e1328d9db6733763a96b33ccea9888d108751c Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 5/8] fix: Enforce Complete DOCX Content Structure --- packages/extraction/src/server.ts | 2 +- packages/extraction/tests/adversarial.py | 70 +++++++++++++++++++ .../extraction/tests/container-client.mjs | 28 +++++++- packages/extraction/worker/extract.py | 53 +++++++++++--- 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts index ed31c1e0..7e56d3a7 100644 --- a/packages/extraction/src/server.ts +++ b/packages/extraction/src/server.ts @@ -16,7 +16,7 @@ export const capabilities = Object.freeze({ docx: 'document body, paragraphs and tables', }, limitations: { - docx: 'Body only. Content controls, custom XML wrappers, revisions, alternate content, simple fields and text boxes are rejected.', + docx: 'Body paragraphs/tables and direct runs/hyperlinks only. Other content structures, fields, images and wrappers are rejected; formatting is ignored.', }, limits: { ...policy, diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index b87ec0ea..ea8f5424 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -189,3 +189,73 @@ member = 'word/_rels/document.bin.rels' target.writestr(member, value) print('DOCX completeness and content-type fixtures generated') + +# The supported structure is closed, not an expanding list of forbidden tags. +for tag in ['dir', 'bdo', 'smartTag', 'unknownWrapper']: + for placement in ['paragraph', 'cell', 'hyperlink']: + document = Document() + document.add_paragraph('Ordinary text must not hide wrapped content') + paragraph = (document.add_table(rows=1, cols=1).cell(0, 0).add_paragraph() + if placement == 'cell' else document.add_paragraph()) + run = paragraph.add_run('Wrapped run text')._r + parent = run.getparent() + if placement == 'hyperlink': + link = OxmlElement('w:hyperlink') + link.set(qn('w:anchor'), 'test') + parent.append(link) + parent = link + wrapper = OxmlElement('w:' + tag) + if tag in ('dir', 'bdo'): + wrapper.set(qn('w:val'), 'rtl') + wrapper.append(run) + parent.append(wrapper) + document.save(root / f'wrapper-{tag}-{placement}.docx') + +# A malformed unknown block and run child cannot bypass the same invariant. +document = Document() +document.add_paragraph('Ordinary text') +paragraph = document.add_paragraph('Unknown body wrapper text') +parent = paragraph._p.getparent() +wrapper = OxmlElement('w:unknownWrapper') +wrapper.append(paragraph._p) +parent.insert(0, wrapper) +document.save(root / 'wrapper-block.docx') +document = Document() +document.add_paragraph('Ordinary text') +run = document.add_paragraph().add_run('Run wrapper text')._r +text = run[0] +wrapper = OxmlElement('w:unknownWrapper') +wrapper.append(text) +run.append(wrapper) +document.save(root / 'wrapper-run.docx') + +# Accepted formatting, hyperlinks, bookmarks and run text primitives retain order. +document = Document() +paragraph = document.add_paragraph() +bookmark = OxmlElement('w:bookmarkStart') +bookmark.set(qn('w:id'), '1') +bookmark.set(qn('w:name'), 'test') +paragraph._p.append(bookmark) +paragraph.add_run('Bold ').bold = True +link = OxmlElement('w:hyperlink') +link.set(qn('w:anchor'), 'test') +run = OxmlElement('w:r') +text = OxmlElement('w:t') +text.text = 'Link' +run.append(text) +link.append(run) +paragraph._p.append(link) +paragraph.add_run('\tTabbed\nNext') +run = paragraph.add_run()._r +run.append(OxmlElement('w:noBreakHyphen')) +run.append(OxmlElement('w:ptab')) +run.append(OxmlElement('w:cr')) +text = OxmlElement('w:t') +text.text = 'End' +run.append(text) +run.append(OxmlElement('w:lastRenderedPageBreak')) +bookmark = OxmlElement('w:bookmarkEnd') +bookmark.set(qn('w:id'), '1') +paragraph._p.append(bookmark) +document.save(root / 'supported-runs.docx') +print('DOCX closed-structure fixtures generated') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index 68ba9078..7bfa91ae 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -90,7 +90,16 @@ for (const name of [ assert.equal(response.status, 200, name); assert.deepEqual(response.value.segments, docx.value.segments, name); } -for (const [name, format, code] of [ +const primitives = await send( + 'docx', + readFileSync(`${fixtures}/supported-runs.docx`) +); +assert.equal(primitives.status, 200); +assert.equal( + primitives.value.segments[0].text, + 'Bold Link\tTabbed\nNext-\t\nEnd\n' +); +const rejectedFixtures = [ ['control.docx', 'docx', 'UNSUPPORTED_CONTENT'], ['control-cell.docx', 'docx', 'UNSUPPORTED_CONTENT'], ['control-inline.docx', 'docx', 'UNSUPPORTED_CONTENT'], @@ -121,7 +130,20 @@ for (const [name, format, code] of [ ['output.docx', 'docx', 'OUTPUT_LIMIT'], ['encrypted.pdf', 'pdf', 'ENCRYPTED_DOCUMENT'], ['too-many-pages.pdf', 'pdf', 'STRUCTURE_LIMIT'], -]) { +]; +for (const tag of ['dir', 'bdo', 'smartTag', 'unknownWrapper']) { + for (const placement of ['paragraph', 'cell', 'hyperlink']) { + rejectedFixtures.push([ + `wrapper-${tag}-${placement}.docx`, + 'docx', + 'UNSUPPORTED_CONTENT', + ]); + } +} +for (const name of ['wrapper-block.docx', 'wrapper-run.docx']) { + rejectedFixtures.push([name, 'docx', 'UNSUPPORTED_CONTENT']); +} +for (const [name, format, code] of rejectedFixtures) { const response = await send(format, readFileSync(`${fixtures}/${name}`)); assert.equal( response.value.error?.code, @@ -161,5 +183,5 @@ pages.copy(padded); assert.equal((await send('pdf', padded)).status, 200); assert.equal((await send('pdf', Buffer.alloc(padded.length + 1))).status, 413); console.log( - 'Container endpoint: PDF/DOCX success, structure, input limits and 30 adversarial fixtures passed' + `Container endpoint: PDF/DOCX success, structure, input limits and ${rejectedFixtures.length} adversarial fixtures passed` ); diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 7446380e..1ce4d639 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -234,20 +234,55 @@ def safe_docx(data): return output.getvalue() +def validate_docx_body(body): + from lxml.etree import _Element + + literal = _Element.text.__get__ + word = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + # These are the exact content paths read by pinned python-docx. Unknown + # wrappers fail closed rather than hiding text from its direct-child XPath. + grammar = { + "body": {"p", "tbl", "sectPr"}, + "tbl": {"tblPr", "tblGrid", "tr"}, + "tr": {"trPr", "tc"}, + "tc": {"tcPr", "p", "tbl"}, + "p": {"pPr", "r", "hyperlink"}, + "hyperlink": {"r"}, + "r": {"rPr", "t", "tab", "br", "cr", "noBreakHyphen", "ptab", "lastRenderedPageBreak"}, + } + properties = {"sectPr", "tblPr", "tblGrid", "trPr", "tcPr", "pPr", "rPr"} + markers = {"bookmarkStart", "bookmarkEnd", "commentRangeStart", "commentRangeEnd", + "permStart", "permEnd", "proofErr"} + stack = [(body, "body")] + while stack: + parent, kind = stack.pop() + if literal(parent) and literal(parent).strip(): + raise Rejected("UNSUPPORTED_CONTENT") + for child in parent: + tag = child.tag.removeprefix(word) if isinstance(child.tag, str) else "" + marker = kind in {"body", "p", "hyperlink", "tc"} and tag in markers + if child.tag != word + tag or (tag not in grammar[kind] and not marker): + raise Rejected("UNSUPPORTED_CONTENT") + if child.tail and child.tail.strip(): + raise Rejected("UNSUPPORTED_CONTENT") + if tag in properties: + # Formatting is ignored, but cannot conceal textual content. + if any(node.tag in {word + "t", word + "instrText", word + "delText"} + or (literal(node) and literal(node).strip()) + or (node.tail and node.tail.strip()) for node in child.iter()): + raise Rejected("UNSUPPORTED_CONTENT") + elif tag in grammar: + stack.append((child, tag)) + elif len(child) or (tag != "t" and literal(child) and literal(child).strip()): + raise Rejected("UNSUPPORTED_CONTENT") + + def docx_segments(data): from docx import Document from docx.table import Table document = Document(io.BytesIO(safe_docx(data))) - word = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" - unsupported = {word + name for name in ( - "sdt", "customXml", "ins", "del", "moveFrom", "moveTo", "altChunk", - "subDoc", "fldSimple", "txbxContent", - )} - unsupported.add("{http://schemas.openxmlformats.org/markup-compatibility/2006}AlternateContent") - # python-docx omits these wrappers. Never return successful partial text. - if any(element.tag in unsupported for element in document.element.iter()): - raise Rejected("UNSUPPORTED_CONTENT") + validate_docx_body(document.element.body) text = Text() def blocks(container, depth=0, cell=False): if depth > 32: From d284d851a70afa2d4f0d25c9b65bc9992996ce6d Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 6/8] fix: Extract Merged DOCX Cells Once --- packages/extraction/src/server.ts | 1 + packages/extraction/tests/adversarial.py | 66 +++++++++++++++++++ .../extraction/tests/container-client.mjs | 37 +++++++++++ packages/extraction/worker/extract.py | 41 +++++++++++- 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts index 7e56d3a7..47179b7a 100644 --- a/packages/extraction/src/server.ts +++ b/packages/extraction/src/server.ts @@ -23,6 +23,7 @@ export const capabilities = Object.freeze({ concurrency: 2, expandedBytes: 32 * 1024 * 1024, archiveEntries: 512, + tableColumns: 512, }, }); const status = (code: string): number => { diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index ea8f5424..7b83b5c6 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -259,3 +259,69 @@ paragraph._p.append(bookmark) document.save(root / 'supported-runs.docx') print('DOCX closed-structure fixtures generated') + +# Physical cells are unique. A merged origin's text must never be repeated. +for name, end in [('horizontal.docx', (0, 1)), ('vertical.docx', (1, 0)), + ('rectangle.docx', (1, 1))]: + document = Document() + table = document.add_table(rows=2, cols=3) + for row in range(2): + for column in range(3): + table.cell(row, column).text = f'R{row}C{column}' + table.cell(0, 0).merge(table.cell(*end)).text = 'Merged text' + document.save(root / name) + +document = Document() +table = document.add_table(rows=2, cols=2) +origin = table.cell(0, 0).merge(table.cell(1, 1)) +origin.text = 'Before merged nested table' +origin.add_table(rows=1, cols=1).cell(0, 0).text = 'Unique nested content' +origin.add_paragraph('After merged nested table') +document.save(root / 'merged-nested.docx') + +for name, end in [('merged-large.docx', (0, 1)), ('merged-large-vertical.docx', (1, 0))]: + document = Document() + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).merge(table.cell(*end)).text = 'x' * (600 * 1024) + document.save(root / name) + +document = Document() +table = document.add_table(rows=1, cols=3) +table.cell(0, 1).text = 'Middle cell' +row = table.rows[0]._tr +row.remove(row.tc_lst[-1]) +row.remove(row.tc_lst[0]) +properties = row.get_or_add_trPr() +for tag in ('gridBefore', 'gridAfter'): + value = OxmlElement('w:' + tag) + value.set(qn('w:val'), '1') + properties.append(value) +document.save(root / 'omitted-cells.docx') + +for name, mode in [('merge-content.docx', 'content'), ('merge-orphan.docx', 'orphan'), + ('merge-span.docx', 'span'), ('merge-legacy.docx', 'legacy'), + ('wide-table.docx', 'width')]: + document = Document() + table = document.add_table(rows=2, cols=2) + if mode == 'legacy': + value = OxmlElement('w:hMerge') + value.set(qn('w:val'), 'restart') + table.cell(0, 0)._tc.get_or_add_tcPr().append(value) + elif mode == 'width': + for _ in range(511): + table._tbl.tblGrid.append(OxmlElement('w:gridCol')) + else: + table.cell(0, 0).merge(table.cell(1, 0)).text = 'Origin text' + continuation = table.rows[1]._tr.tc_lst[0] + if mode == 'content': + continuation.p_lst[0].append(OxmlElement('w:r')) + text = OxmlElement('w:t') + text.text = 'Unexpected continuation text' + continuation.p_lst[0][-1].append(text) + elif mode == 'orphan': + table.rows[0]._tr.tc_lst[0].get_or_add_tcPr().remove( + table.rows[0]._tr.tc_lst[0].tcPr.vMerge) + elif mode == 'span': + continuation.get_or_add_tcPr().get_or_add_gridSpan().val = 2 + document.save(root / name) +print('DOCX merged and omitted-cell fixtures generated') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index 7bfa91ae..5ccd260b 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -99,7 +99,44 @@ assert.equal( primitives.value.segments[0].text, 'Bold Link\tTabbed\nNext-\t\nEnd\n' ); +for (const [name, expected] of [ + ['horizontal.docx', 'Merged text\t\tR0C2\nR1C0\tR1C1\tR1C2\n'], + ['vertical.docx', 'Merged text\tR0C1\tR0C2\n\tR1C1\tR1C2\n'], + ['rectangle.docx', 'Merged text\t\tR0C2\n\t\tR1C2\n'], + ['omitted-cells.docx', '\tMiddle cell\t\n'], +]) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} +const mergedNested = await send( + 'docx', + readFileSync(`${fixtures}/merged-nested.docx`) +); +assert.equal(mergedNested.status, 200); +assert.equal( + mergedNested.value.segments[0].text.match(/Unique nested content/g).length, + 1 +); +assert.match( + mergedNested.value.segments[0].text, + /Before merged nested table[\n]+Unique nested content[\n]+After merged nested table/ +); +for (const name of ['merged-large.docx', 'merged-large-vertical.docx']) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal( + response.value.segments[0].text.match(/x/g).length, + 600 * 1024, + name + ); +} const rejectedFixtures = [ + ['merge-content.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['merge-orphan.docx', 'docx', 'INVALID_DOCUMENT'], + ['merge-span.docx', 'docx', 'INVALID_DOCUMENT'], + ['merge-legacy.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['wide-table.docx', 'docx', 'STRUCTURE_LIMIT'], ['control.docx', 'docx', 'UNSUPPORTED_CONTENT'], ['control-cell.docx', 'docx', 'UNSUPPORTED_CONTENT'], ['control-inline.docx', 'docx', 'UNSUPPORTED_CONTENT'], diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 1ce4d639..acbad6bf 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -279,7 +279,7 @@ def validate_docx_body(body): def docx_segments(data): from docx import Document - from docx.table import Table + from docx.table import Table, _Cell document = Document(io.BytesIO(safe_docx(data))) validate_docx_body(document.element.body) @@ -293,13 +293,48 @@ def blocks(container, depth=0, cell=False): text.add("\n") first = False if isinstance(block, Table): + width = len(block._tbl.tblGrid.gridCol_lst) + if width < 1 or width > 512: + raise Rejected("STRUCTURE_LIMIT") + previous = set() for row_index, row in enumerate(block.rows): if row_index: text.add("\n") - for index, child in enumerate(row.cells): + before, after = row.grid_cols_before, row.grid_cols_after + if before < 0 or after < 0 or before + after >= width: + raise Rejected("INVALID_DOCUMENT") + column = before + text.add("\t" * before) + current = set() + for index, tc in enumerate(row._tr.tc_lst): + span, merge = tc.grid_span, tc.vMerge + if span < 1 or column + span > width - after: + raise Rejected("INVALID_DOCUMENT") + if tc.tcPr is not None and tc.tcPr.find( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}hMerge") is not None: + raise Rejected("UNSUPPORTED_CONTENT") if index: text.add("\t") - blocks(child, depth + 1, cell=True) + position = (column, span) + if merge == "continue": + if position not in previous: + raise Rejected("INVALID_DOCUMENT") + # Continuations occupy columns, not another copy of + # the originating cell. Hidden content is rejected. + if tc.xpath(".//w:t | .//w:tbl | .//w:tab | .//w:br | .//w:cr | .//w:noBreakHyphen | .//w:ptab"): + raise Rejected("UNSUPPORTED_CONTENT") + else: + blocks(_Cell(tc, block), depth + 1, cell=True) + if merge is not None: + if merge not in {"restart", "continue"}: + raise Rejected("INVALID_DOCUMENT") + current.add(position) + text.add("\t" * (span - 1)) + column += span + if column + after != width: + raise Rejected("INVALID_DOCUMENT") + text.add("\t" * after) + previous = current if not cell: text.add("\n") else: From 5337f3ab97384fad9adbc2f9c058969036de2ba3 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 7/8] fix: Reject Unmappable PDF Font Text --- packages/extraction/src/server.ts | 1 + packages/extraction/tests/adversarial.py | 116 ++++++++++++++++++ .../extraction/tests/container-client.mjs | 41 +++++++ packages/extraction/worker/extract.py | 59 ++++++++- 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts index 47179b7a..b3bd37c8 100644 --- a/packages/extraction/src/server.ts +++ b/packages/extraction/src/server.ts @@ -16,6 +16,7 @@ export const capabilities = Object.freeze({ docx: 'document body, paragraphs and tables', }, limitations: { + pdf: 'Text only. Simple fonts need interpretable standard/Unicode mappings. Type3/composite fonts need complete Unicode maps. Unmapped glyphs, codec recovery and graphics-state font selection are rejected.', docx: 'Body paragraphs/tables and direct runs/hyperlinks only. Other content structures, fields, images and wrappers are rejected; formatting is ignored.', }, limits: { diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index 7b83b5c6..bbe734f8 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -325,3 +325,119 @@ continuation.get_or_add_tcPr().get_or_add_gridSpan().val = 2 document.save(root / name) print('DOCX merged and omitted-cell fixtures generated') + +from pypdf.generic import FloatObject, TextStringObject + +def font_pdf(name, subtype, raw=b'A', mapping=None, glyph='/unmappedGlyph', encoding=None, form=False): + writer = PdfWriter() + writer.append(root / 'pages.pdf') + page = writer.pages[0] + font = DictionaryObject({NameObject('/Type'): NameObject('/Font'), + NameObject('/Subtype'): NameObject('/' + subtype), + NameObject('/BaseFont'): NameObject('/Helvetica')}) + if subtype == 'Type3': + proc = DecodedStreamObject() + proc.set_data(b'600 0 0 0 500 700 d1 0 0 500 700 re f') + font[NameObject('/CharProcs')] = DictionaryObject({NameObject(glyph): writer._add_object(proc)}) + font[NameObject('/FontBBox')] = ArrayObject([NumberObject(v) for v in [0, 0, 500, 700]]) + font[NameObject('/FontMatrix')] = ArrayObject([FloatObject(v) for v in [0.001, 0, 0, 0.001, 0, 0]]) + font[NameObject('/Resources')] = DictionaryObject() + font[NameObject('/FirstChar')] = NumberObject(65) + font[NameObject('/LastChar')] = NumberObject(66) + font[NameObject('/Widths')] = ArrayObject([NumberObject(600), NumberObject(600)]) + if subtype == 'Type0': + descendant = DictionaryObject({ + NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/CIDFontType2'), + NameObject('/BaseFont'): NameObject('/TestComposite'), + NameObject('/CIDSystemInfo'): DictionaryObject({ + NameObject('/Registry'): TextStringObject('Adobe'), + NameObject('/Ordering'): TextStringObject('Identity'), NameObject('/Supplement'): NumberObject(0), + }), + NameObject('/FontDescriptor'): DictionaryObject({ + NameObject('/Type'): NameObject('/FontDescriptor'), NameObject('/FontName'): NameObject('/TestComposite'), + NameObject('/Flags'): NumberObject(4), NameObject('/Ascent'): NumberObject(800), + NameObject('/Descent'): NumberObject(-200), NameObject('/CapHeight'): NumberObject(700), + NameObject('/ItalicAngle'): NumberObject(0), NameObject('/StemV'): NumberObject(80), + NameObject('/FontBBox'): ArrayObject([NumberObject(v) for v in [0, -200, 1000, 800]]), + }), NameObject('/CIDToGIDMap'): NameObject('/Identity'), + }) + font[NameObject('/DescendantFonts')] = ArrayObject([writer._add_object(descendant)]) + font[NameObject('/Encoding')] = NameObject('/Identity-H') + else: + font[NameObject('/Encoding')] = DictionaryObject({ + NameObject('/BaseEncoding'): NameObject('/WinAnsiEncoding'), + NameObject('/Differences'): ArrayObject([NumberObject(65), NameObject(glyph)]), + }) + if encoding: + font[NameObject('/Encoding')] = NameObject(encoding) + if mapping is not None: + cmap = DecodedStreamObject() + size = 2 if subtype == 'Type0' else 1 + start, end = ('0000', 'FFFF') if size == 2 else ('00', 'FF') + pairs = '\n'.join(f'<{code:0{size*2}X}> <{value.encode("utf-16-be", errors="surrogatepass").hex()}>' + for code, value in mapping.items()) + cmap.set_data((f'/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n' + f'/CMapName /TestUnicode def\n/CMapType 2 def\n' + f'1 begincodespacerange\n<{start}> <{end}>\nendcodespacerange\n' + f'{len(mapping)} beginbfchar\n{pairs}\nendbfchar\nendcmap\n' + f'CMapName currentdict /CMap defineresource pop\nend\nend').encode('ascii')) + font[NameObject('/ToUnicode')] = writer._add_object(cmap.flate_encode()) + resources = DictionaryObject({NameObject('/Font'): DictionaryObject({NameObject('/F2'): writer._add_object(font)})}) + stream = DecodedStreamObject() + stream.set_data(b'BT /F2 12 Tf 72 700 Td <' + raw.hex().encode() + b'> Tj ET') + if form: + stream[NameObject('/Type')] = NameObject('/XObject') + stream[NameObject('/Subtype')] = NameObject('/Form') + stream[NameObject('/BBox')] = ArrayObject([NumberObject(v) for v in [0, 0, 612, 792]]) + stream[NameObject('/Resources')] = resources + page['/Resources'][NameObject('/XObject')] = DictionaryObject({NameObject('/FontForm'): writer._add_object(stream)}) + content = page['/Contents'].get_object() + content.set_data(content.get_data() + b'\n/FontForm Do') + else: + page[NameObject('/Resources')] = resources + page[NameObject('/Contents')] = writer._add_object(stream) + writer.write(root / name) + +font_pdf('font-type3-unmapped.pdf', 'Type3') +font_pdf('font-simple-unmapped.pdf', 'Type1') +font_pdf('font-form-unmapped.pdf', 'Type3', form=True) +font_pdf('font-partial-map.pdf', 'Type3', raw=b'AB', mapping={65: 'A'}) +font_pdf('font-empty-map.pdf', 'Type3', mapping={}) +font_pdf('font-bad-unicode.pdf', 'Type3', mapping={65: '\ud800'}) +font_pdf('font-composite-unmapped.pdf', 'Type0', raw=b'\x00A') +font_pdf('font-codec-fallback.pdf', 'Type0', raw=b'\x01', mapping={1: 'A'}) +font_pdf('font-composite-partial.pdf', 'Type0', raw=b'\x00\x01\x00\x02', mapping={1: 'A'}) +font_pdf('font-type3-unicode.pdf', 'Type3', mapping={65: '\u03a9'}) +font_pdf('font-simple-unicode.pdf', 'Type1', mapping={65: '\u03a9'}) +font_pdf('font-known-glyph.pdf', 'Type1', glyph='/Aacute') +font_pdf('font-ligature.pdf', 'Type3', mapping={65: 'fi'}) +font_pdf('font-emoji.pdf', 'Type3', mapping={65: '\U0001f600'}) +font_pdf('font-composite-unicode.pdf', 'Type0', raw=b'\x00\x01\x00\x02', mapping={1: 'A', 2: 'fi'}) +font_pdf('font-form-unicode.pdf', 'Type3', mapping={65: '\u03a9'}, form=True) +writer = PdfWriter() +writer.append(root / 'pages.pdf') +content = writer.pages[0]['/Contents'].get_object() +content.set_data(content.get_data().replace(b'/F1', b'/MissingFont')) +writer.write(root / 'font-missing.pdf') +print('PDF font mapping fixtures generated') + +font_pdf('font-replacement.pdf', 'Type3', mapping={65: '\ufffd'}) +font_pdf('font-partial-simple.pdf', 'Type1', raw=b'AB', mapping={65: 'A'}) +writer = PdfWriter() +writer.append(root / 'pages.pdf') +del writer.pages[0]['/Resources'] +writer.write(root / 'font-no-resources.pdf') +writer = PdfWriter() +writer.append(root / 'pages.pdf') +del writer.pages[0]['/Resources'] +writer.pages[0]['/Contents'].get_object().set_data(b'q 0 0 10 10 re f Q') +writer.write(root / 'no-resources-graphics.pdf') +writer = PdfWriter() +writer.append(root / 'pages.pdf') +page = writer.pages[0] +page['/Resources'][NameObject('/ExtGState')] = DictionaryObject({NameObject('/GS1'): DictionaryObject({ + NameObject('/Font'): ArrayObject([page['/Resources']['/Font'].raw_get('/F1'), NumberObject(12)]), +})}) +stream = page['/Contents'].get_object() +stream.set_data(b'/GS1 gs\n' + stream.get_data()) +writer.write(root / 'font-graphics-state.pdf') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index 5ccd260b..f4cb1e6f 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -131,7 +131,48 @@ for (const name of ['merged-large.docx', 'merged-large-vertical.docx']) { name ); } +for (const [name, expected] of [ + ['font-type3-unicode.pdf', '\u03a9'], + ['font-simple-unicode.pdf', '\u03a9'], + ['font-known-glyph.pdf', '\u00c1'], + ['font-ligature.pdf', 'fi'], + ['font-emoji.pdf', '\ud83d\ude00'], + ['font-composite-unicode.pdf', 'Afi'], +]) { + const response = await send('pdf', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} +const fontForm = await send( + 'pdf', + readFileSync(`${fixtures}/font-form-unicode.pdf`) +); +assert.equal(fontForm.status, 200); +assert.match(fontForm.value.segments[0].text, /First page text[\n]+\u03a9/); +const noResources = await send( + 'pdf', + readFileSync(`${fixtures}/no-resources-graphics.pdf`) +); +assert.equal(noResources.status, 200); +assert.deepEqual( + noResources.value.segments.map(segment => segment.text), + ['', 'Second page text'] +); const rejectedFixtures = [ + ['font-replacement.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-partial-simple.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-no-resources.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-graphics-state.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-type3-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-simple-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-form-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-partial-map.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-empty-map.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-bad-unicode.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-composite-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-codec-fallback.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-composite-partial.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-missing.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], ['merge-content.docx', 'docx', 'UNSUPPORTED_CONTENT'], ['merge-orphan.docx', 'docx', 'INVALID_DOCUMENT'], ['merge-span.docx', 'docx', 'INVALID_DOCUMENT'], diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index acbad6bf..21726455 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -43,12 +43,15 @@ def value(self): def pdf_segments(data): import pypdf - from pypdf import filters - from pypdf.generic import DecodedStreamObject + from pypdf import filters, _page + from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject + from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS + from pypdf._text_extraction._text_extractor import TextExtraction used = 0 failure = None credited = {} + shown_bytes = 0 def reject(code): nonlocal failure @@ -70,6 +73,8 @@ def emit(self, record): logger.propagate = False original = filters.decode_stream_data original_data = DecodedStreamObject.get_data + original_show = TextExtraction._handle_tj_operation + original_resources = _page._get_page_resources def inflate(value): nonlocal used @@ -116,6 +121,56 @@ def decode(stream): except Exception: reject("INVALID_DOCUMENT") + def resources_for_text(obj): + resources = original_resources(obj) + if resources: + for state in resources.get("/ExtGState", DictionaryObject()).get_object().values(): + if "/Font" in state.get_object(): + reject("UNSUPPORTED_ENCODING") + return resources + # Do not let the parser skip text operators solely because resources + # are absent. A used missing font must reach the rejection below. + return DictionaryObject({NameObject("/Font"): DictionaryObject()}) + + def show_text(extractor, operands): + nonlocal shown_bytes + if operands and operands[0]: + font, resource = extractor.font, extractor.font_resource + if resource is None or not font.interpretable: + reject("UNSUPPORTED_ENCODING") + if font.sub_type not in {"Type1", "MMType1", "TrueType", "Type3", "Type0"}: + reject("UNSUPPORTED_ENCODING") + require_map = ("/ToUnicode" in resource or font.sub_type in {"Type3", "Type0"} + or isinstance(font.encoding, str) + or ("/Encoding" not in resource and font.name not in CORE_FONT_METRICS)) + value = operands[0] + if isinstance(value, bytes): + try: + if isinstance(font.encoding, str): + # Never take pypdf's charmap recovery on codec failure. + characters = value.decode(font.encoding, errors="surrogatepass") + else: + characters = (font.encoding[code] for code in value) + for character in characters: + if require_map and character not in font.character_map: + reject("UNSUPPORTED_ENCODING") + mapped = font.character_map.get(character, character) + # Unknown Adobe glyph names are preserved as NameObject. + if (not isinstance(mapped, str) or isinstance(mapped, NameObject) + or not mapped or "\ufffd" in mapped + or any(ord(char) < 32 and char not in "\t\n\r" for char in mapped)): + reject("UNSUPPORTED_ENCODING") + shown_bytes += len(mapped.encode("utf-8", errors="strict")) + if shown_bytes > TEXT_BYTES: + reject("OUTPUT_LIMIT") + except (LookupError, UnicodeError, TypeError): + reject("UNSUPPORTED_ENCODING") + elif not isinstance(value, str): + reject("UNSUPPORTED_ENCODING") + return original_show(extractor, operands) + + _page._get_page_resources = resources_for_text + TextExtraction._handle_tj_operation = show_text DecodedStreamObject.get_data = decoded_data filters.decompress = inflate filters.decode_stream_data = decode From 520b49dd76eecf1a493fe2b6c32b07375f7e2989 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 4 Oct 2026 16:01:11 +0000 Subject: [PATCH 8/8] fix: Normalize Optional PDF Font Encodings --- packages/extraction/tests/adversarial.py | 45 +++++++++++++++++++ .../extraction/tests/container-client.mjs | 38 ++++++++++++++++ packages/extraction/worker/extract.py | 32 ++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py index bbe734f8..710c3a11 100644 --- a/packages/extraction/tests/adversarial.py +++ b/packages/extraction/tests/adversarial.py @@ -441,3 +441,48 @@ def font_pdf(name, subtype, raw=b'A', mapping=None, glyph='/unmappedGlyph', enco stream = page['/Contents'].get_object() stream.set_data(b'/GS1 gs\n' + stream.get_data()) writer.write(root / 'font-graphics-state.pdf') + +from pypdf.generic import NullObject + +# Null optional dictionary entries mean absent, including indirect nulls. +for subtype in ['Type1', 'TrueType']: + for mode in ['absent', 'null', 'indirect-null', 'empty-dict', 'null-base', 'invalid-number']: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + font = writer.pages[0]['/Resources']['/Font']['/F1'] + font[NameObject('/Subtype')] = NameObject('/' + subtype) + font[NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') + if mode == 'null': + font[NameObject('/Encoding')] = NullObject() + elif mode == 'indirect-null': + font[NameObject('/Encoding')] = writer._add_object(NullObject()) + elif mode == 'empty-dict': + font[NameObject('/Encoding')] = DictionaryObject() + elif mode == 'null-base': + font[NameObject('/Encoding')] = DictionaryObject({NameObject('/BaseEncoding'): NullObject()}) + elif mode == 'invalid-number': + font[NameObject('/Encoding')] = NumberObject(1) + writer.write(root / f'font-{subtype.lower()}-{mode}.pdf') + +for mode in ['null', 'indirect-null', 'null-unicode', 'indirect-null-unicode', 'null-base']: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + font = writer.pages[0]['/Resources']['/Font']['/F1'] + if mode == 'null-base': + font[NameObject('/Encoding')] = DictionaryObject({NameObject('/BaseEncoding'): NullObject()}) + else: + key = '/ToUnicode' if 'unicode' in mode else '/Encoding' + font[NameObject(key)] = writer._add_object(NullObject()) if mode.startswith('indirect') else NullObject() + writer.write(root / f'font-core-{mode}.pdf') + +writer = PdfWriter() +writer.append(root / 'font-simple-unicode.pdf') +font = writer.pages[0]['/Resources']['/Font']['/F2'] +font[NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') +font[NameObject('/Encoding')] = NullObject() +writer.write(root / 'font-null-with-unicode.pdf') +writer = PdfWriter() +writer.append(root / 'font-known-glyph.pdf') +writer.pages[0]['/Resources']['/Font']['/F2'][NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') +writer.write(root / 'font-custom-standard.pdf') +print('PDF null/absent encoding fixtures generated') diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs index f4cb1e6f..14e70f74 100644 --- a/packages/extraction/tests/container-client.mjs +++ b/packages/extraction/tests/container-client.mjs @@ -158,6 +158,28 @@ assert.deepEqual( noResources.value.segments.map(segment => segment.text), ['', 'Second page text'] ); +for (const mode of [ + 'null', + 'indirect-null', + 'null-unicode', + 'indirect-null-unicode', + 'null-base', +]) { + const response = await send( + 'pdf', + readFileSync(`${fixtures}/font-core-${mode}.pdf`) + ); + assert.equal(response.status, 200, mode); + assert.deepEqual(response.value.segments, result.value.segments, mode); +} +for (const [name, expected] of [ + ['font-null-with-unicode.pdf', '\u03a9'], + ['font-custom-standard.pdf', '\u00c1'], +]) { + const response = await send('pdf', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} const rejectedFixtures = [ ['font-replacement.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], ['font-partial-simple.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], @@ -209,6 +231,22 @@ const rejectedFixtures = [ ['encrypted.pdf', 'pdf', 'ENCRYPTED_DOCUMENT'], ['too-many-pages.pdf', 'pdf', 'STRUCTURE_LIMIT'], ]; +for (const subtype of ['type1', 'truetype']) { + for (const mode of [ + 'absent', + 'null', + 'indirect-null', + 'empty-dict', + 'null-base', + 'invalid-number', + ]) { + rejectedFixtures.push([ + `font-${subtype}-${mode}.pdf`, + 'pdf', + 'UNSUPPORTED_ENCODING', + ]); + } +} for (const tag of ['dir', 'bdo', 'smartTag', 'unknownWrapper']) { for (const placement of ['paragraph', 'cell', 'hyperlink']) { rejectedFixtures.push([ diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py index 21726455..20f0360f 100644 --- a/packages/extraction/worker/extract.py +++ b/packages/extraction/worker/extract.py @@ -44,7 +44,8 @@ def value(self): def pdf_segments(data): import pypdf from pypdf import filters, _page - from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject + from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject, NullObject, StreamObject + from pypdf._cmap import charset_encoding from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS from pypdf._text_extraction._text_extractor import TextExtraction @@ -124,6 +125,25 @@ def decode(stream): def resources_for_text(obj): resources = original_resources(obj) if resources: + fonts = resources.get("/Font") + if fonts is not None and not isinstance(fonts.get_object(), NullObject): + for ref in fonts.get_object().values(): + resource = ref.get_object() + for key in ("/Encoding", "/ToUnicode"): + if key in resource and isinstance(resource[key], NullObject): + del resource[key] + encoding = resource.get("/Encoding") + if encoding is not None: + encoding = encoding.get_object() + if not isinstance(encoding, (NameObject, DictionaryObject)): + reject("UNSUPPORTED_ENCODING") + if isinstance(encoding, DictionaryObject): + base = encoding.get("/BaseEncoding") + if base is not None and isinstance(base.get_object(), NullObject): + del encoding["/BaseEncoding"] + unicode_map = resource.get("/ToUnicode") + if unicode_map is not None and not isinstance(unicode_map.get_object(), StreamObject): + reject("UNSUPPORTED_ENCODING") for state in resources.get("/ExtGState", DictionaryObject()).get_object().values(): if "/Font" in state.get_object(): reject("UNSUPPORTED_ENCODING") @@ -140,9 +160,17 @@ def show_text(extractor, operands): reject("UNSUPPORTED_ENCODING") if font.sub_type not in {"Type1", "MMType1", "TrueType", "Type3", "Type0"}: reject("UNSUPPORTED_ENCODING") + encoding = resource.get("/Encoding") + if encoding is not None: + encoding = encoding.get_object() + if isinstance(encoding, DictionaryObject): + encoding = encoding.get("/BaseEncoding") + if encoding is not None: + encoding = encoding.get_object() + standard_encoding = isinstance(encoding, NameObject) and encoding in charset_encoding require_map = ("/ToUnicode" in resource or font.sub_type in {"Type3", "Type0"} or isinstance(font.encoding, str) - or ("/Encoding" not in resource and font.name not in CORE_FONT_METRICS)) + or (not standard_encoding and font.name not in CORE_FONT_METRICS)) value = operands[0] if isinstance(value, bytes): try: