From 5a9fbc3207f667e313c3639468a861d79b7a4454 Mon Sep 17 00:00:00 2001 From: xueyadong Date: Tue, 13 Oct 2026 07:10:43 +0000 Subject: [PATCH] fix(backend): restrict file access to registered task images Validate completed-task PNG artifacts and share checks between legacy and task-image endpoints. Preserve image previews, default local listeners to loopback, and document deployment access controls. Add 86 security and pipeline regression cases covering traversal, symlinks, manifests, relative output directories, and image/PDF previews. --- apps/backend/README.md | 46 +- apps/backend/app/api/tasks.py | 99 ++-- apps/backend/app/core/steps/merge_results.py | 34 +- apps/backend/app/utils/config.py | 4 +- apps/backend/app/utils/task_images.py | 146 ++++++ apps/backend/pytest.ini | 5 +- apps/backend/tests/conftest.py | 102 +++++ apps/backend/tests/test_task_images.py | 446 +++++++++++++++++++ apps/frontend/vite.config.ts | 4 +- apps/start-docker.sh | 4 +- 10 files changed, 809 insertions(+), 81 deletions(-) create mode 100644 apps/backend/app/utils/task_images.py create mode 100644 apps/backend/tests/conftest.py create mode 100644 apps/backend/tests/test_task_images.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 5b636fb..844cfa9 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -114,8 +114,10 @@ uv sync # 基础配置 APP_NAME=OCR Task System DEBUG=False -HOST=0.0.0.0 +HOST=127.0.0.1 PORT=8000 +# 浏览器可访问的 API 地址,用于 OCR 结果中的图片链接 +PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 # 数据库 DATABASE_URL=sqlite+aiosqlite:///./tasks.db @@ -132,14 +134,47 @@ TASK_TIMEOUT=3600 ```bash # 方式1:使用 uv -uv run uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 +uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 # 方式2:直接运行 -python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 +python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 ``` 访问 API 文档:http://localhost:8000/docs +### 文件访问与部署边界 + +图片预览接口为 `GET /api/v1/tasks/{task_id}/images/{image_id}`,仅返回已完成 +任务的 PNG 裁剪图片。图片必须位于 `OUTPUT_DIR//`,并且登记在该任务 +`ocr_result.json` 的 `ref_image_paths` 中。文件名、真实目录和 PNG 内容均会校验; +符号链接、目录穿越、未登记图片、上传原件、配置、数据库和任意文本文件不可通过此接口读取。 +无效请求统一返回 `404 {"detail":"Image not found"}`,不返回服务器文件路径。 + +旧结果中的 `GET /api/v1/tasks/file?path=...` 图片链接仍可使用,但执行完全相同的 +任务和产物校验,不再提供通用文件读取功能。保留旧任务时,需要一起保留任务数据库记录、 +裁剪图片和 `ocr_result.json`。新生成的 Markdown 使用图片 ID,不包含服务器绝对文件路径。 + +本应用仍是共享工作区,没有内置用户登录和任务归属隔离。默认开发服务和 Docker 宿主端口 +仅绑定本机;容器内部仍监听 `0.0.0.0` 以供端口转发。若要对外服务,请通过统一鉴权的 +网关或反向代理开放前端及全部 API,并阻止用户绕过代理直连后端。多用户数据隔离还需要 +任务归属权限校验,不能将 UUID 当作授权凭证。 + +反向代理部署时,将 `PUBLIC_API_BASE_URL` 配置为浏览器能够访问、且受到相同访问控制的 +API 地址(例如 `https://ocr.example.com/api/v1`)。图片使用 `` 加载,代理的 +认证方式需要支持图片请求,例如同源会话 Cookie。更改该配置只影响新生成的结果链接。 + +### 安全回归测试 + +在 `apps/backend` 目录、安装后端依赖的环境中运行: + +```bash +python -m pytest tests +``` + +测试使用真实 FastAPI 路由、临时 SQLite 数据库及临时文件,不读取已有任务数据库。 +图片和 PDF 的上传、转换、裁剪、结果合并、新旧预览链接均参与测试;仅外部 OCR 模型 +响应使用固定样本,不需要 GPU 或在线模型服务。 + ## 📖 API 使用指南 ### 统一响应格式 @@ -235,7 +270,7 @@ docker build -t glm-ocr:latest . ```bash docker run -d \ --name glm-ocr \ - -p 8000:8000 \ + -p 127.0.0.1:8000:8000 \ -v $(pwd)/data:/backend/data \ -e WORKER_COUNT=5 \ glm-ocr:latest @@ -282,8 +317,9 @@ uv add --dev pytest | 配置项 | 说明 | 默认值 | |-------|------|--------| | `APP_NAME` | 应用名称 | OCR Task System | -| `HOST` | 监听地址 | 0.0.0.0 | +| `HOST` | 直接启动时的监听地址 | 127.0.0.1 | | `PORT` | 监听端口 | 8000 | +| `PUBLIC_API_BASE_URL` | 新结果图片链接使用的浏览器可访问 API 地址 | http://localhost:8000/api/v1 | | `DATABASE_URL` | 数据库连接 | sqlite+aiosqlite:///./tasks.db | | `OUTPUT_DIR` | 输出目录 | ./data | | `WORKER_COUNT` | Worker 数量 | 5 | diff --git a/apps/backend/app/api/tasks.py b/apps/backend/app/api/tasks.py index 9be900b..6bceb90 100644 --- a/apps/backend/app/api/tasks.py +++ b/apps/backend/app/api/tasks.py @@ -9,13 +9,19 @@ from datetime import datetime, UTC from fastapi import APIRouter, HTTPException, status, UploadFile, File, Form, Response -from mimetypes import guess_type +from starlette.concurrency import run_in_threadpool from app.schemas.response import ApiResponse, TaskData, TaskResultData from app.core.task_manager import get_task_manager from app.utils.logger import logger from app.utils.upload_file_manager import file_upload_handler from app.utils.config import settings +from app.utils.task_images import ( + TaskImageNotFound, + image_id_from_path, + read_task_image, + validate_image_id, +) router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -102,76 +108,51 @@ async def submit_task( @router.get("/file") async def read_file(path: str): - """ - 读取指定路径的文件内容 - - 对于图片文件,直接返回图片数据 - 对于其他文件,返回JSON格式的文件信息 - - - **path**: 文件路径 - """ + """兼容旧结果中的图片链接;仅允许访问已完成任务的裁剪图片。""" try: - file_path = Path(path) - - # 检查文件是否存在 - if not file_path.exists(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"File not found: {path}", - ) + task_id, image_id = image_id_from_path(path) + except TaskImageNotFound: + raise HTTPException(status_code=404, detail="Image not found") from None + return await _task_image_response(task_id, image_id) - # 检查是否为文件 - if not file_path.is_file(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Path is not a file: {path}", - ) - # 获取文件MIME类型 - mime_type, _ = guess_type(file_path.name) - if mime_type is None: - mime_type = "application/octet-stream" - - # 读取文件内容 - with open(file_path, "rb") as f: - content = f.read() - - # 如果是图片文件,直接返回二进制数据 - if mime_type.startswith("image/"): - return Response( - content=content, - media_type=mime_type, - headers={ - "Content-Disposition": f"inline; filename=\"{file_path.name}\"" - } - ) +@router.get("/{task_id}/images/{image_id}") +async def get_task_image(task_id: str, image_id: str): + """获取任务生成并登记的裁剪图片,不接受服务器文件路径。""" + return await _task_image_response(task_id, image_id) - # 其他文件类型,返回JSON格式 - try: - text_content = content.decode("utf-8") - except UnicodeDecodeError: - text_content = "(binary file)" - return ApiResponse( - success=True, - data={ - "path": str(file_path.absolute()), - "filename": file_path.name, - "size": file_path.stat().st_size, - "mime_type": mime_type, - "content": text_content, +async def _task_image_response(task_id: str, image_id: str): + try: + validate_image_id(task_id, image_id) + task = await get_task_manager().get_task_status(task_id) + # Uploaded files cannot supply their own manifest before OCR completes. + if ( + not task + or task.get("status") != "completed" + or not task.get("result_file_path") + ): + raise TaskImageNotFound + content = await run_in_threadpool(read_task_image, task_id, image_id) + return Response( + content=content, + media_type="image/png", + headers={ + "Content-Disposition": f'inline; filename="{image_id}"', + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", }, - message="File read successfully", ) - + except TaskImageNotFound: + raise HTTPException(status_code=404, detail="Image not found") from None except HTTPException: raise except Exception as e: - logger.error(f"Failed to read file: {e}") + logger.error(f"Failed to read task image: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read file: {str(e)}", - ) + detail="Failed to read task image", + ) from None @router.get("/{task_id}", response_model=ApiResponse[dict]) diff --git a/apps/backend/app/core/steps/merge_results.py b/apps/backend/app/core/steps/merge_results.py index 89d8bde..fbd5ec3 100644 --- a/apps/backend/app/core/steps/merge_results.py +++ b/apps/backend/app/core/steps/merge_results.py @@ -6,9 +6,12 @@ from pathlib import Path import json import os +from html import escape from app.core.flows.base import ProcessingContext from app.utils.logger import logger +from app.utils.config import settings +from app.utils.task_images import TaskImageNotFound, image_id_from_path class MergeResultsStepInput: @@ -99,19 +102,30 @@ async def _merge_to_markdown( result["metadata"] = context.metadata merge_res_layout = [] total_pages = len(pages) - for i, page in enumerate(pages): - # page_num = page.get("page_number", i + 1) + for page in pages: layout = page.get("layout", {}).get("blocks", []) - for i, block in enumerate(layout): + for block in layout: text = block.get("content", "") layout_type = block.get("layout_type", "") if layout_type == "image": - img_name = block.get("image_path") - # 将相对路径转换为绝对路径 - if img_name and not os.path.isabs(img_name): - img_name = os.path.abspath(img_name) - - text = f'
Image
\n' + try: + image_path = block.get("image_path") + if isinstance(image_path, str): + image_path = os.path.abspath(image_path) + task_id, image_id = image_id_from_path(image_path) + if task_id != context.task_id: + raise TaskImageNotFound + image_url = ( + f"{settings.PUBLIC_API_BASE_URL.rstrip('/')}/tasks/" + f"{task_id}/images/{image_id}" + ) + text = ( + '
' + f'Image
\n' + ) + except TaskImageNotFound: + # Failed crops have no image_path; do not emit a broken URL. + text = text or "" markdown_lines.append(f"{text}\n") merge_res_layout.append( { @@ -123,7 +137,7 @@ async def _merge_to_markdown( ) if progress_callback: progress = 100.0 - await progress_callback(progress, f"Merging page {i + 1}/{total_pages}") + await progress_callback(progress, f"Merged {total_pages} pages") result["full_markdown"] = "".join(markdown_lines) result["layout"] = merge_res_layout # 写入文件 diff --git a/apps/backend/app/utils/config.py b/apps/backend/app/utils/config.py index 196fff3..3c51bfc 100644 --- a/apps/backend/app/utils/config.py +++ b/apps/backend/app/utils/config.py @@ -13,8 +13,10 @@ class Settings(BaseSettings): APP_NAME: str = "OCR Task System" APP_VERSION: str = "1.0.0" DEBUG: bool = False - HOST: str = "0.0.0.0" + HOST: str = "127.0.0.1" PORT: int = 8000 + # Browser-reachable API base used in generated Markdown image links. + PUBLIC_API_BASE_URL: str = "http://localhost:8000/api/v1" # 数据库配置 DATABASE_URL: str = "sqlite+aiosqlite:///./tasks.db" diff --git a/apps/backend/app/utils/task_images.py b/apps/backend/app/utils/task_images.py new file mode 100644 index 0000000..2f01b67 --- /dev/null +++ b/apps/backend/app/utils/task_images.py @@ -0,0 +1,146 @@ +"""Read only the PNG crops explicitly recorded by a task's OCR pipeline.""" + +import json +import os +import re +import stat +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from uuid import UUID + +from PIL import Image + +from app.utils.config import settings + +_IMAGE_NAME = re.compile(r"split_[1-9][0-9]*_[0-9]{4,}\.png\Z") + + +class TaskImageNotFound(Exception): + """The request does not identify a readable, registered task image.""" + + +def validate_image_id(task_id: str, image_id: str) -> None: + try: + valid = str(UUID(task_id)) == task_id and _IMAGE_NAME.fullmatch(image_id) + except (ValueError, AttributeError): + valid = False + if not valid: + raise TaskImageNotFound + + +def image_id_from_path(path: str) -> tuple[str, str]: + """Translate legacy paths without accepting traversal or arbitrary subtrees. + + Legacy results contain absolute paths (or paths relative to the backend's + working directory), not paths relative to OUTPUT_DIR. + """ + if not isinstance(path, str) or not path or "\x00" in path: + raise TaskImageNotFound + candidate = Path(path) + if ".." in candidate.parts: + raise TaskImageNotFound + candidate = candidate.absolute() + root = Path(settings.OUTPUT_DIR).absolute() + try: + # Accept both spellings when the administrator's OUTPUT_DIR is a symlink. + for base in (root, root.resolve(strict=True)): + if candidate.is_relative_to(base): + parts = candidate.relative_to(base).parts + if len(parts) == 2: + validate_image_id(*parts) + return parts[0], parts[1] + except (OSError, RuntimeError, ValueError) as exc: + raise TaskImageNotFound from exc + raise TaskImageNotFound + + +@contextmanager +def _task_directory(root: Path, task_id: str): + """Pin the directory on POSIX so a symlink swap cannot redirect a read.""" + task_dir = root / task_id + if task_dir.is_symlink() or task_dir.resolve(strict=True) != task_dir: + raise TaskImageNotFound + if os.open in os.supports_dir_fd and hasattr(os, "O_NOFOLLOW"): + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + root_fd = os.open(root, flags) + try: + task_fd = os.open(task_id, flags, dir_fd=root_fd) + try: + yield task_dir, task_fd + finally: + os.close(task_fd) + finally: + os.close(root_fd) + else: + # Platforms without openat still reject symlinks and resolved escapes. + yield task_dir, None + + +def _read_regular_file(task_dir: Path, task_fd: int | None, name: str) -> bytes: + path = task_dir / name + if path.is_symlink() or path.resolve(strict=True).parent != task_dir: + raise TaskImageNotFound + flags = ( + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_BINARY", 0) + ) + fd = ( + os.open(name, flags, dir_fd=task_fd) + if task_fd is not None + else os.open(path, flags) + ) + with os.fdopen(fd, "rb") as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + raise TaskImageNotFound + return source.read() + + +def read_task_image(task_id: str, image_id: str) -> bytes: + """Use the generated manifest as an allowlist, then verify the PNG contents.""" + validate_image_id(task_id, image_id) + try: + root = Path(settings.OUTPUT_DIR).resolve(strict=True) + with _task_directory(root, task_id) as (task_dir, task_fd): + manifest = json.loads( + _read_regular_file(task_dir, task_fd, "ocr_result.json") + ) + if not isinstance(manifest, dict) or manifest.get("success") is not True: + raise TaskImageNotFound + references = manifest.get("ref_image_paths") + if not isinstance(references, list): + raise TaskImageNotFound + registered = False + for reference in references: + if not isinstance(reference, str): + continue + try: + # Stored pipeline paths can contain a relative OUTPUT_DIR + # such as ../data. Only normalize these internal references; + # the public legacy input still rejects parent traversal. + if image_id_from_path(os.path.abspath(reference)) == ( + task_id, + image_id, + ): + registered = True + break + except TaskImageNotFound: + continue + if not registered: + raise TaskImageNotFound + content = _read_regular_file(task_dir, task_fd, image_id) + with Image.open(BytesIO(content)) as image: + if image.format != "PNG": + raise TaskImageNotFound + image.verify() + return content + except ( + OSError, + ValueError, + RuntimeError, + SyntaxError, + Image.DecompressionBombError, + ) as exc: + raise TaskImageNotFound from exc diff --git a/apps/backend/pytest.ini b/apps/backend/pytest.ini index ea79657..12abf4b 100644 --- a/apps/backend/pytest.ini +++ b/apps/backend/pytest.ini @@ -1,4 +1,5 @@ -[tool:pytest] +[pytest] +pythonpath = . testpaths = tests python_files = test_*.py python_classes = Test* @@ -13,4 +14,4 @@ markers = slow: marks tests as slow (deselect with '-m "not slow"') integration: marks tests as integration tests unit: marks tests as unit tests - real_api: marks tests that make real API calls \ No newline at end of file + real_api: marks tests that make real API calls diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py new file mode 100644 index 0000000..e975c4c --- /dev/null +++ b/apps/backend/tests/conftest.py @@ -0,0 +1,102 @@ +"""Integration fixtures use the real app and a temporary SQLite database.""" + +import json +from io import BytesIO +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + + +@pytest.fixture(scope="session") +def client(tmp_path_factory): + sandbox = tmp_path_factory.mktemp("backend") + with pytest.MonkeyPatch.context() as patch: + # Set these before importing the app: never initialize the user's DB. + patch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{sandbox / 'tasks.db'}") + patch.setenv("OUTPUT_DIR", str(sandbox / "output")) + patch.setenv("WORKER_COUNT", "0") + patch.setenv("PUBLIC_API_BASE_URL", "http://testserver/api/v1") + from app.main import app + + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def output_dir(client, tmp_path, monkeypatch): + from app.utils.config import settings + + directory = tmp_path / "output # & 中文" + directory.mkdir() + monkeypatch.setattr(settings, "OUTPUT_DIR", str(directory)) + return directory + + +@pytest.fixture +def png_bytes(): + stream = BytesIO() + Image.new("RGB", (40, 30), "blue").save(stream, format="PNG") + return stream.getvalue() + + +@pytest.fixture +def complete_task(client): + def complete(task_id, result_path, task_status="completed"): + async def update(): + from app.db.database import AsyncSessionLocal + from app.repository.task import TaskRepository + + async with AsyncSessionLocal() as session: + await TaskRepository(session).update_task_status( + task_id, task_status, result_file_path=str(result_path) + ) + await session.commit() + + client.portal.call(update) + + return complete + + +@pytest.fixture +def artifact(client, output_dir, png_bytes, complete_task): + response = client.post( + "/api/v1/tasks/upload", + files={"file": ("input.png", png_bytes, "image/png")}, + ) + assert response.status_code == 201 + task_id = response.json()["data"]["task_id"] + directory = output_dir / task_id + image = directory / "split_1_0001.png" + image.write_bytes(png_bytes) + manifest = directory / "ocr_result.json" + manifest.write_text( + json.dumps({"success": True, "ref_image_paths": [str(image)]}), + encoding="utf-8", + ) + result = directory / "merged.json" + result.write_text('{"full_markdown": "existing result"}', encoding="utf-8") + complete_task(task_id, result) + return SimpleNamespace( + task_id=task_id, + directory=directory, + image=image, + manifest=manifest, + result=result, + content=png_bytes, + ) + + +@pytest.fixture(params=["legacy", "resource"]) +def image_request(request, client, artifact): + def get(): + if request.param == "legacy": + return client.get( + "/api/v1/tasks/file", params={"path": str(artifact.image)} + ) + return client.get( + f"/api/v1/tasks/{artifact.task_id}/images/{artifact.image.name}" + ) + + return get diff --git a/apps/backend/tests/test_task_images.py b/apps/backend/tests/test_task_images.py new file mode 100644 index 0000000..9aaf328 --- /dev/null +++ b/apps/backend/tests/test_task_images.py @@ -0,0 +1,446 @@ +"""Security and preview regressions against real HTTP routes and task records.""" + +import json +import os +import shutil +from html.parser import HTMLParser +from io import BytesIO +from pathlib import Path +from unittest.mock import AsyncMock +from urllib.parse import quote, urlsplit +from uuid import uuid4 + +import pytest +from PIL import Image + + +def assert_hidden(response): + assert response.status_code == 404 + assert response.json() == {"detail": "Image not found"} + + +def test_registered_preview(image_request, artifact): + response = image_request() + assert response.status_code == 200 + assert response.content == artifact.content + assert response.headers["content-type"] == "image/png" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["cache-control"] == "private, no-store" + assert "authorization" not in response.request.headers + assert "cookie" not in response.request.headers + + +@pytest.mark.parametrize( + "path", + [ + "/etc/passwd", + "/proc/self/environ", + "/app/../etc/hostname", + "/app/app/utils/config.py", + "/app/data/tasks.db", + "", + "\x00", + ], +) +def test_report_paths_are_blocked(client, output_dir, path): + assert_hidden(client.get("/api/v1/tasks/file", params={"path": path})) + + +@pytest.mark.parametrize( + "name", + [ + "tasks.db", + "config.py", + "ocr_result.json", + "merged.json", + "input.png", + "images/page_0001.png", + "split_1_0002.png", + "split_1_0001.svg", + ], +) +def test_non_artifacts_inside_output_are_blocked(client, artifact, output_dir, name): + path = (output_dir if name == "tasks.db" else artifact.directory) / name + if not path.exists(): + path.parent.mkdir(exist_ok=True) + path.write_bytes(artifact.content) + assert_hidden(client.get("/api/v1/tasks/file", params={"path": str(path)})) + + +def test_prefix_lookalike_is_not_within_root(client, artifact, output_dir): + outside = output_dir.with_name(output_dir.name + "_backup") + shutil.copytree(artifact.directory, outside / artifact.task_id) + path = outside / artifact.task_id / artifact.image.name + assert_hidden(client.get("/api/v1/tasks/file", params={"path": str(path)})) + + +@pytest.mark.parametrize("encoding", ["plain", "encoded", "double_encoded"]) +def test_traversal_is_blocked(client, artifact, output_dir, encoding): + path = f"{artifact.directory}/../{artifact.task_id}/{artifact.image.name}" + if encoding == "plain": + response = client.get("/api/v1/tasks/file", params={"path": path}) + else: + encoded = quote(path, safe="").replace("..", "%2e%2e") + if encoding == "double_encoded": + encoded = quote(encoded, safe="") + response = client.get(f"/api/v1/tasks/file?path={encoded}") + assert_hidden(response) + + +def test_legacy_relative_paths_still_work(client, artifact, output_dir, monkeypatch): + from app.utils.config import settings + + monkeypatch.chdir(output_dir.parent) + monkeypatch.setattr(settings, "OUTPUT_DIR", output_dir.name) + relative = artifact.image.relative_to(output_dir.parent) + artifact.manifest.write_text( + json.dumps( + { + "success": True, + "ref_image_paths": [str(relative)], + } + ), + encoding="utf-8", + ) + response = client.get("/api/v1/tasks/file", params={"path": str(relative)}) + assert response.status_code == 200 + assert response.content == artifact.content + + +def test_configured_output_symlink_is_supported( + client, + artifact, + output_dir, + tmp_path, + monkeypatch, +): + from app.utils.config import settings + + alias = tmp_path / "configured-storage" + alias.symlink_to(output_dir, target_is_directory=True) + monkeypatch.setattr(settings, "OUTPUT_DIR", str(alias)) + for root in (alias, output_dir): + response = client.get( + "/api/v1/tasks/file", + params={ + "path": str(root / artifact.task_id / artifact.image.name), + }, + ) + assert response.status_code == 200 + assert response.content == artifact.content + + +def test_matching_basename_in_other_task_is_not_registration(image_request, artifact): + other = artifact.directory.parent / str(uuid4()) / artifact.image.name + artifact.manifest.write_text( + json.dumps( + { + "success": True, + "ref_image_paths": [str(other)], + } + ), + encoding="utf-8", + ) + assert_hidden(image_request()) + + +@pytest.mark.parametrize( + "manifest", + [ + None, + [], + {}, + {"success": False, "ref_image_paths": []}, + {"success": True, "ref_image_paths": "not a list"}, + {"success": True, "ref_image_paths": [None, {}, "/etc/passwd"]}, + {"success": True, "ref_image_paths": []}, + ], +) +def test_unregistered_image_is_blocked(image_request, artifact, manifest): + artifact.manifest.write_text(json.dumps(manifest), encoding="utf-8") + assert_hidden(image_request()) + + +def test_missing_or_broken_manifest_is_blocked(image_request, artifact): + artifact.manifest.write_text("{broken JSON", encoding="utf-8") + assert_hidden(image_request()) + artifact.manifest.unlink() + assert_hidden(image_request()) + + +@pytest.mark.parametrize("kind", ["text", "jpeg", "truncated_png"]) +def test_disguised_non_png_is_not_returned(image_request, artifact, kind): + if kind == "jpeg": + Image.new("RGB", (2, 2)).save(artifact.image, format="JPEG") + else: + artifact.image.write_bytes( + b"private configuration" if kind == "text" else artifact.content[:20] + ) + assert_hidden(image_request()) + + +@pytest.mark.parametrize("target_kind", ["image", "manifest", "directory", "loop"]) +def test_symlink_escape_is_blocked(image_request, artifact, tmp_path, target_kind): + outside = tmp_path / "outside" + if target_kind == "directory": + artifact.directory.rename(outside) + artifact.directory.symlink_to(outside, target_is_directory=True) + else: + path = artifact.manifest if target_kind == "manifest" else artifact.image + if target_kind != "loop": + outside.write_bytes(path.read_bytes()) + path.unlink() + path.symlink_to(path if target_kind == "loop" else outside) + assert_hidden(image_request()) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX special file") +def test_fifo_is_rejected_without_blocking(image_request, artifact): + artifact.image.unlink() + os.mkfifo(artifact.image) + assert_hidden(image_request()) + + +@pytest.mark.skipif(not hasattr(os, "O_NOFOLLOW"), reason="POSIX no-follow open") +@pytest.mark.parametrize("target_kind", ["image", "directory"]) +def test_symlink_swap_after_resolution_is_blocked( + image_request, + artifact, + tmp_path, + monkeypatch, + target_kind, +): + target = artifact.image if target_kind == "image" else artifact.directory + outside = tmp_path / "swapped-outside" + original_resolve = Path.resolve + swapped = False + + def racing_resolve(path, *args, **kwargs): + nonlocal swapped + result = original_resolve(path, *args, **kwargs) + if path == target and not swapped: + swapped = True + target.rename(outside) + target.symlink_to(outside, target_is_directory=target_kind == "directory") + return result + + monkeypatch.setattr(Path, "resolve", racing_resolve) + assert_hidden(image_request()) + assert swapped + + +@pytest.mark.parametrize( + "task_status", ["pending", "processing", "failed", "cancelled"] +) +def test_unfinished_tasks_cannot_supply_manifests( + image_request, + artifact, + complete_task, + task_status, +): + complete_task(artifact.task_id, artifact.result, task_status) + assert_hidden(image_request()) + + +def test_unknown_task_is_blocked(client, artifact, output_dir): + unknown = str(uuid4()) + shutil.copytree(artifact.directory, output_dir / unknown) + assert_hidden(client.get(f"/api/v1/tasks/{unknown}/images/{artifact.image.name}")) + + +def test_completed_task_without_registered_result_is_blocked( + image_request, + artifact, + complete_task, +): + complete_task(artifact.task_id, "") + assert_hidden(image_request()) + + +def test_internal_error_does_not_disclose_paths(image_request, monkeypatch): + from app.api import tasks + + def fail(*args): + raise OSError("private /deployment/configuration/path") + + monkeypatch.setattr(tasks, "read_task_image", fail) + response = image_request() + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to read task image"} + + +@pytest.mark.parametrize( + "task_id,image_id", + [ + ("not-a-uuid", "split_1_0001.png"), + (str(uuid4()), "tasks.db"), + (str(uuid4()), "split_1_0001.png%00"), + (str(uuid4()), "..%5Csplit_1_0001.png"), + ], +) +def test_invalid_resource_ids_are_blocked(client, task_id, image_id): + assert_hidden(client.get(f"/api/v1/tasks/{task_id}/images/{image_id}")) + + +class ImageSources(HTMLParser): + def __init__(self): + super().__init__() + self.sources = [] + + def handle_starttag(self, tag, attrs): + if tag == "img": + self.sources.append(dict(attrs)["src"]) + + +@pytest.mark.parametrize("input_format", ["png", "pdf"]) +@pytest.mark.parametrize("root_style", ["absolute", "relative", "parent_relative"]) +def test_upload_pipeline_result_and_preview( + client, + output_dir, + png_bytes, + complete_task, + monkeypatch, + input_format, + root_style, +): + """Only the external model response is stubbed; all app processing is real.""" + from app.core.flows.base import ProcessingContext + from app.core.flows.pipeline_flow import PipelineFlow + from app.core.ocr_client import LayoutAndOCRClient + from app.utils.config import settings + + if root_style != "absolute": + workdir = output_dir.parent + if root_style == "parent_relative": + workdir = workdir / "backend-workdir" + workdir.mkdir() + monkeypatch.chdir(workdir) + monkeypatch.setattr( + settings, "OUTPUT_DIR", os.path.relpath(output_dir, workdir) + ) + + payload = png_bytes + if input_format == "pdf": + import fitz + + with fitz.open() as document: + document.new_page(width=144, height=144) + document.new_page(width=144, height=144) + payload = document.tobytes() + response = client.post( + "/api/v1/tasks/upload", + files={ + "file": ( + f"document.{input_format}", + payload, + "application/pdf" if input_format == "pdf" else "image/png", + ), + }, + ) + assert response.status_code == 201 + task_id = response.json()["data"]["task_id"] + directory = output_dir / task_id + model = AsyncMock( + return_value=[ + {"label": "text", "bbox_2d": [0, 0, 500, 200], "content": "OCR test text"}, + {"label": "image", "bbox_2d": [0, 200, 500, 800], "content": None}, + ] + ) + monkeypatch.setattr(LayoutAndOCRClient, "process_single_image", model) + context = ProcessingContext( + task_id=task_id, + document_id=task_id, + file_path=str(directory / f"document.{input_format}"), + file_type=input_format, + processing_mode="pipeline", + ocr_config={}, + output_format="markdown", + ) + result = client.portal.call(PipelineFlow(context).process) + complete_task(task_id, result["json_output_path"]) + response = client.get(f"/api/v1/tasks/{task_id}") + assert response.status_code == 200 + data = response.json()["data"] + markdown = data["full_markdown"] + assert "OCR test text" in markdown + assert str(output_dir) not in markdown + assert "?path=" not in markdown + parser = ImageSources() + parser.feed(markdown) + expected_pages = 2 if input_format == "pdf" else 1 + assert model.await_count == expected_pages + assert len(parser.sources) == expected_pages + assert len(data["layout"]) == expected_pages * 2 + assert Path(result["md_output_path"]).read_text(encoding="utf-8") == markdown + for source in parser.sources: + assert source.startswith(f"http://testserver/api/v1/tasks/{task_id}/images/") + preview = client.get(source) + assert preview.status_code == 200 + with Image.open(BytesIO(preview.content)) as image: + assert image.format == "PNG" + image.verify() + image_id = Path(urlsplit(source).path).name + legacy = client.get( + "/api/v1/tasks/file", params={"path": str(directory / image_id)} + ) + assert legacy.status_code == 200 + assert legacy.content == preview.content + + +def test_merge_without_images_or_pages(client, output_dir): + from app.core.flows.base import ProcessingContext + from app.core.steps.merge_results import _merge_to_markdown + + context = ProcessingContext( + task_id=str(uuid4()), + document_id="test", + file_path="unused", + file_type="png", + processing_mode="pipeline", + ocr_config={}, + output_format="markdown", + ) + progress = AsyncMock() + _, result = client.portal.call( + _merge_to_markdown, context, {}, str(output_dir), progress + ) + assert json.loads(Path(result).read_text(encoding="utf-8"))["full_markdown"] == "" + progress.assert_awaited_once_with(100.0, "Merged 0 pages") + + +def test_missing_crop_does_not_generate_broken_link(client, artifact): + from app.core.flows.base import ProcessingContext + from app.core.steps.merge_results import _merge_to_markdown + + context = ProcessingContext( + task_id=artifact.task_id, + document_id="test", + file_path="unused", + file_type="png", + processing_mode="pipeline", + ocr_config={}, + output_format="markdown", + ) + ocr_results = { + "pages": [ + { + "layout": { + "blocks": [ + {"layout_type": "image", "image_path": None, "content": None}, + {"layout_type": "text", "content": "Text remains visible"}, + ] + } + } + ] + } + _, result = client.portal.call( + _merge_to_markdown, + context, + ocr_results, + str(artifact.directory), + None, + ) + markdown = json.loads(Path(result).read_text(encoding="utf-8"))["full_markdown"] + assert "Text remains visible" in markdown + assert "