Skip to content

service-storage: sys_file lifecycle columns (status, size, mime_type) are writable through the data door, so a never-uploaded file can be marked committed and then claimed by a file field #27399

Closed

Description

@objectstack-fleet

domain:devx#1 · sunhaolin · session_017xRBcTp5wXse9Zwgq8aFWV

QA-source: #27392 · attachments-storage.field-accept-maxsize-server-enforced · observed beside the clauses

What happens

sys_file records the storage service's own view of an upload: status (pending → committed → deleted), size and mime_type. Two checks read those columns:

But sys_file declares these columns as ordinary writable fields, so PATCH /api/v1/data/sys_file/ID rewrites them. A caller allowed to write sys_file rows can therefore:

  • mark a presigned upload committed without its bytes ever arriving, and have a record's file field accept it (201);
  • rewrite size or mime_type after commit, so the field constraints judge values that do not describe the stored bytes.

On a stock boot only an administrator reaches this. A member's PATCH of sys_file answers 403. It is an integrity gap in the storage lifecycle, not a privilege escalation. The object's own header says the lifecycle status is tracked so that clients cannot spoof backend facts.

Reproduction (fresh showcase boot, admin signed in through the form)

  1. POST /api/v1/storage/upload/presigned with {"filename":"ghost.png","mimeType":"image/png","size":1234} → 200; read fileId. Do not PUT any bytes and do not call complete.
  2. GET /api/v1/data/sys_file/FILE_ID → status: pending.
  3. PATCH /api/v1/data/sys_file/FILE_ID with {"status":"committed","size":999} → 200.
  4. GET /api/v1/data/sys_file/FILE_ID → status: committed, size: 999.
  5. On an object with a file field, for example the qa-media-constraints recipe's qamedia_media.poster, POST /api/v1/data/qamedia_media with {"name":"ghost","poster":"FILE_ID"} → 201, and the file is claimed.
  • Expected: the lifecycle columns are owned by the storage service. A data-door write to status, size, mime_type (and the storage key and etag) is refused or ignored, and a never-uploaded file stays pending.
  • Actual: the PATCH answers 200, and the file field then accepts a file that has no bytes.

This was reproduced twice at c4c515ad, on two independent boots.

Mechanism

packages/services/service-storage/src/objects/system-file.object.ts declares status as a plain Field.select and size / mime_type as plain fields. Neither is readonly, and no storage-owned write guard applies. The data door's generic update therefore writes them like any other column.

Where a fix would go

Mark the storage-owned lifecycle columns readonly on the data door, so that only the storage service's own writes (complete, chunk, delete) move them. Alternatively, have the file-field claim re-verify the held bytes rather than trusting the row.

Full evidence chain: QA run #27392.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: 文件 | none | P②

    Triage: p2 · domain:server · area:files · 2026-10-29T05:59Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. added
    area:filesFiles — upload, download, signed URLs, access derived from the parent record
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    and removed on Oct 29, 2026
  3. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    Claim: PM loop round 3 · card thread
    Session: session_01L6bhgUCizoYWQVJWjhdR6F (the seat); the card thread is session_01L6bhgWddm8zjbee2kUdmmA
    Account: os-sales
    Branch: claude/project-thread-36yup5 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: objectstack-issue-27399 in the card thread's own container
    Domain: domain:server
    Seat: domain:server#2
    File surface: packages/services/service-storage/src/objects/system-file.object.ts (the lifecycle columns), the service-storage writers of sys_file (only to make a non-system write system-context, if one is found), one service-storage-owned create-side guard for a non-system sys_file insert (a hook or validation in packages/services/service-storage, since the engine's static readonly strip exempts sys_ objects on insert), and tests. ⛔ Not packages/objectql (the engine-wide sys_ insert exemption stays as it is; stop and report if the guard cannot live in the storage service). Stop on breach; explain in the report.
    Container & model: M, mode:thread, model: default (dispatch-gates --tier: no path-derived mandate), effort: high
    Clause-②: no (narrowing)
    Metadata-protocol: none
    Responsibility: the storage service's sys_file object (this lane) | the engine strips readonly columns on update but exempts sys_ objects on insert | any non-system writer of sys_file through the data door
    Thread-read: 6489236866
    Premise: holds (read on main 2026-10-29T20:58Z and by the card thread: the lifecycle columns are plain writable fields; on update the static readonly strip would apply, on insert sys_ objects are exempt)
    Serial constraints cleared: none (no open PR edits these files). A contract review is owed before approval.

  4. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    os-dev-report

    {
      "issue": 27399,
      "status": "done",
      "branch": "claude/project-thread-36yup5",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/27765",
      "session": "session_01L6bhgWddm8zjbee2kUdmmA",
      "premise_still_valid": true,
      "summary": "sys_file's five storage-owned columns (key, mime_type, size, status, etag) are now readonly on the object, so the engine's existing static readonly strip keeps the stored values on every non-system UPDATE (by id and by predicate; stripped with a warn, not refused). Measured: on INSERT the engine exempts every sys_ object (staticReadonlyInsertSubject returns null), so a new service-storage beforeInsert guard (installSysFileStorageOwnedColumnsGuard) drops those five from a non-system create and starts the row pending; no refusal added on either verb, packages/objectql untouched. Every writer of the columns was already system context (StorageMetadataStore for presign / complete / chunked / field-file / attach, file-reference claim-release-copy, attachment tombstone, backfills); none changed. Moved pins: the #11076 dogfood admin contrast control (it pinned the admin write landing, the behaviour this card reverses) and the plugin wiring pin; system-context census row 47g added for the guard's isSystem read.",
      "tests": "New src/sys-file-storage-owned-columns.integration.test.ts, 12/12 on the #26479 rig (card steps 1-5, each column by id, predicate update, single and batch create, presign-PUT-complete-claim end to end, system create keeps values). Ablation via scripts/ablation-replace.mjs, fix committed first, restore blob == HEAD each leg, src-imported so no dist leg: leg 1 every readonly true to false in system-file.object.ts (6 hits) 10 failed / 2 passed; leg 2 guard install deleted 2 failed / 10 passed; leg 3 guard isSystem exemption deleted 8 failed / 4 passed. Final head aebcc2cd: pnpm --filter @objectstack/service-storage test and typecheck under the lock VERDICT command-exit 0, 71/71 files, 1299/1299 tests; eslint --no-inline-config on the package and the changed dogfood file exit 0.",
      "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 94 families at aebcc2cd, all 94 run on that head, each exit 0; --ran reconcile: 94 run, 0 NOT-MEASURED (a DERIVED zero). First-run reds fixed in the diff: check:adr-0087-registration (no-migration-prescription marker added), check:system-context-census (row 47g). Prerequisites cleared: plugin-teardown self-test fixture commit fetched; 7 dists built for check:dual-build-cjs-loads. No open PR (17 read) touches these files. CI on PR 27765: in_progress at report time (Lint, Type Check lanes started).",
      "dogfood_battery": "aebcc2cd · VERDICT command-exit 0 · Test Files 354 passed / 3 skipped (357) · Tests 2858 passed / 20 skipped (2878) · no failure. First run on 6f79d563 had 1 failure, sys-file-metadata-write-refusal.dogfood.test.ts admin contrast control, moved in this PR.",
      "mcp_calls": "6 — reads only: issue_read (get, get_comments x3), pull_request_read (get 27693, get_check_runs 27765), list_pull_requests. No MCP write tool.",
      "api_writes": "2 — git push of the branch (not REST); relay pr_create (POST pulls, run 40967169876, body read back identical); this os-dev-report comment via post-stamped (POST issues/27399/comments).",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: the field-reference ownership columns ref_object / ref_id / ref_field and released_ref_object / released_ref_id on sys_file are plain writable fields to a caller allowed to write sys_file (code reading only, no door measured) · dedupe words: sys_file ref_object writable, file ownership columns data door, released_ref_id spoof",
        "deviations: the order asked for pnpm lint; os-dev.md leaves the repo-wide sweep to CI, so eslint ran on the changed package and file. The create guard does not read strictReadonlyWrites (no data door passes it). PR attribution lines use this card thread's link and footer session (session-context requirement), with the seat signature as a body line."
      ]
    }
  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    ACCEPT → PR #27765 · head aebcc2cd · 2026-10-29T22:58Z · landing sent (contract review PASS 6510442206). Follow-ups: #27778 (review should-fix 1 and 2), #27767 (ref_* ownership columns).

  6. added a commit that references this issue on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:filesFiles — upload, download, signed URLs, access derived from the parent recordbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions