Skip to content

Measure whether /meta/* write routes refuse an unauthenticated (@anon) principal — server-side half of objectui#5828 #11373

Description

@claude

Filed by the triage seat (session session_01LsWeHbPzR3i6mmonFfGykk, 2026-08-23) as the framework-side half of objectstack-ai/objectui#5828, which observed that objectui guest/previewMode boots write an @anon metadata seed and could not decide benignity from the client side. Duplicate-checked this session (control-word-verified search: zero open cards on /meta anon-write enforcement).

The question (measure first, then fix or close)

Do the /meta/* (metadata REST) WRITE routes refuse an unauthenticated principal server-side?

  • If yes (a 401/403 is enforced before any write): objectui#5828 is benign by construction — record the measured refusal (pin it if unpinned) and close both as measured-shut.
  • If no (an @anon principal can persist metadata seeds/items): that is an unauthenticated write path to platform metadata — a real security defect; the fix lands wherever the guard belongs (REST metadata mount / route middleware), and the card should be re-graded with security severity at that point.

Scope

Measurement + at most the missing guard. ⛔ Not a redesign of metadata auth; if the measurement exposes a design fork (e.g. intentional anonymous read-vs-write asymmetry that documentation contradicts), stop and report for the decision inbox.

Refs


Generated by Claude Code

Activity

  1. added theissue type on Aug 23, 2026
  2. self-assigned this
    on Aug 23, 2026
  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Claim — domain:cli seat

    Session session_019siH5jDmk5hrayvfyojUqR, branch claude/issue-11373-meta-anon-write-measure. pm:queue → pm:dispatched and assignee set in one stroke, read back.

    Declared file surface: packages/rest/** test files (the measurement + its pin) and, only if the measurement exposes a missing guard, the REST metadata mount/middleware that owns it.

    ⛔ Hard serial fence: packages/rest/src/rest-server.ts is held by in-flight #11095 (PR #11392, awaiting contract review). Ruling ① is same-file HARD SERIAL with no region exemption. If the measurement finds the guard missing and the fix lands in that file, the dev stops with the reading recorded rather than editing it — this card then goes pm:blocked + Blocked-by: #11095. A guard landing in any other file is in scope.

    Dispatched with the card's own stop-and-report clause intact: a design fork (intentional anon read-vs-write asymmetry that documentation contradicts) is not this card's to resolve — it stops and reports for the decision inbox.

    Downstream waiting: objectstack-ai/objectui#5828 is parked on this reading.


    Generated by Claude Code

  4. claude commented on Aug 23, 2026

    @claude
    ContributorAuthor
    {
      "issue": 11373,
      "status": "done",
      "branch": "claude/issue-11373-meta-anon-write-measure",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11472",
      "premise_still_valid": true,
      "summary": "MEASURED: the /meta write routes DO refuse an unauthenticated principal, so objectui#5828 is benign by construction server-side and no guard was missing. All six mutating /meta doors (_migrate-stored, single save, reset, publish, rollback, compound save) were driven as REAL HTTP against a booted showcase: anonymous -> 401 UNAUTHENTICATED (rest-flat envelope) with nothing persisted; an authenticated member -> 403 from the separate manage_metadata gate; the dev admin, same URL/method/body -> the door runs and the PUT actually persisted (read-back flipped 404 -> 200), which is the reverse-check proving the 401 is the auth floor and not a broken probe. The refusal was real but pinned only in the WRONG LAYER: rest/src/meta-write-door-capability-enumeration.test.ts asserts the 401 over a vi.fn() transport by calling route.handler directly, which cannot show that the composed app routes a real PUT into the guarded registrar; and the dogfood real-mount file's entire /meta face was one anonymous GET. This PR is test-only and adds that missing end-to-end pin (anonymous + member legs, both non-mutating so the file stays eligible for the shared showcase) plus envelope-family classification of each door as the anti-vacuity guard, and a note on the authz-conformance matrix row. HARD SERIAL FENCE RESPECTED: packages/rest/src/rest-server.ts was NOT edited and needed no edit; no file under packages/rest/ is touched. NOT a contract-review PR: no accept-set changed, no security boundary moved. No design fork found: documentation and behaviour agree that /meta is anonymously unreachable except the declaration-derived audience:'public' book/doc READ, which isPublicAudienceRead restricts to GET before anything else.",
      "tests": "All runs on final commit e29fbd89 (worktree /home/user/objectstack-11373, now torn down). Heavy work serialized through scripts/pm/os-verify-lock.sh; verdicts read from the lock's own VERDICT line and each gate's own output, exit codes captured before any pipe.\n\n=== THE MEASUREMENT (primary deliverable) ===\nBooted showcase, bootStack(showcaseStack), in-process Hono app, platform-default security (no requireAuth override). Identical URL/method/body across all three legs.\n\nLEG 1 anonymous (no credential):\n  POST   /meta/_migrate-stored          -> 401 {\"error\":\"UNAUTHENTICATED\",\"code\":\"UNAUTHENTICATED\",\"message\":\"Authentication is required to access this endpoint.\"}\n  PUT    /meta/object/:name             -> 401 (same body)\n  DELETE /meta/object/:name             -> 401 (same body)\n  POST   /meta/object/:name/publish     -> 401 (same body)\n  POST   /meta/object/:name/rollback    -> 401 (same body)\n  PUT    /meta/object/views/:name       -> 401 (same body)\nPERSISTENCE after LEG 1: GET /meta/object/anon_probe_obj as ADMIN -> 404 {\"error\":\"Metadata item object/anon_probe_obj not found\",\"code\":\"RESOURCE_NOT_FOUND\"}  <- nothing written; not a gate that answers after the save\n\nLEG 2 authenticated member (fresh sign-up, no authoring capability) — all six 403:\n  _migrate-stored -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Rewriting stored metadata requires the `manage_metadata` capability.\"}}\n  save            -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Saving a metadata item requires the `manage_metadata` capability.\"}}\n  reset           -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Resetting a metadata item requires the `manage_metadata` capability.\"}}\n  publish         -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Publishing a metadata item requires the `manage_metadata` capability.\"}}\n  rollback        -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Rolling back a metadata item requires the `manage_metadata` capability.\"}}\n  compound save   -> 403 {\"error\":{\"code\":\"FORBIDDEN\",\"message\":\"Saving a metadata item requires the `manage_metadata` capability.\"}}\n\nLEG 3 dev admin (REVERSE CHECK — the probe must succeed for someone):\n  POST   /meta/_migrate-stored       -> 200 {\"apply\":false,\"protocol\":\"17.0.0\",\"scanned\":0,\"canonical\":0,\"pending\":0,\"rewritten\":0,\"skipped\":0,\"failed\":0,\"rows\":[]}\n  PUT    /meta/object/:name          -> 200 {\"success\":true,\"version\":\"sha256:1e426f9c1e76a0ea5a8f38f98645cc0f0fe9ef7f38411ea3c68bb704580f7f31\",\"seq\":1,\"state\":\"active\",\"message\":\"Saved object 'anon_probe_obj' (env-wide, state=active) [seq=1]\"}\n  DELETE /meta/object/:name          -> 200 {\"success\":true,\"reset\":false,\"message\":\"No customization overlay found for object/showcase_task - already at artifact default.\"}\n  POST   /meta/object/:name/publish  -> 404 {\"error\":\"[no_draft] No pending draft exists for object/showcase_task - nothing to publish.\",\"code\":\"NO_DRAFT\"}\n  POST   /meta/object/:name/rollback -> 404 {\"error\":\"[version_not_found] No history row at version 1 for object/showcase_task.\",\"code\":\"VERSION_NOT_FOUND\"}\n  PUT    /meta/object/views/:name    -> 422 {\"error\":\"[invalid_metadata] ... failed spec validation\",\"code\":\"INVALID_METADATA\"}\nPERSISTENCE after LEG 3: GET /meta/object/anon_probe_obj as ADMIN -> 200 {\"type\":\"object\",\"name\":\"anon_probe_obj\",...}  <- 404 -> 200: the door really writes when authorized\n\nControls: anonymous GET /meta/types -> 401. Anonymous PUT /environments/default/meta/object/... -> 404 ENDPOINT_NOT_FOUND (scoped mount not served; enableProjectScoping defaults false) - filed as #11473.\n\n=== ABLATION — the new pin is proven able to fail ===\nThe fenced rest-server.ts could not be touched, so the ablation targeted the shared decision the umbrella calls: shouldDenyAnonymous in packages/core/src/security/anonymous-deny.ts, forced to return false. Mutation CONFIRMED ON DISK before building - injected-marker grep -c = 1 AND the original guard 'if (input.userId || input.isSystem) return false;' still grep -c = 1, i.e. a bypass was added rather than an anchor silently replaced (a zero-hit edit would have left an unmutated file reading as a successful ablation). REBUILT @objectstack/core - required because dogfood resolves @objectstack/core through exports to dist/, not src/ - and 'node scripts/ablation-dist-preflight.mjs @objectstack/core ABLATION_11373_DENY_DISABLED' exited 0, proving it reached dist/. Script carried trap restore EXIT INT TERM.\n  [ablate] on-disk injected-marker count: 1\n  [ablate] on-disk original-guard count : 1\n  [ablate] core build exit=0\n  [ablate] preflight(present) exit=0\n  [ablate] ABLATED TEST exit=1 -> Test Files 1 failed (1) | Tests 30 failed | 14 passed (44)\n  [ablate] TRAP/restore leg\n  [ablate] restored injected-marker count: 0\n  [ablate] restore build exit=0\n  [ablate] preflight(--absent) exit=0\n  [ablate] RESTORED TEST exit=0 -> Test Files 1 passed (1) | Tests 44 passed (44)\nAll TWELVE new anonymous cases went red (six 'is denied (401)' + six envelope-family rows), e.g.: 'AssertionError: anon PUT /meta/:type/:name (save): anonymous must be denied with the shared status: expected 403 to be 401'. RESTORE LEG RUN AND VERIFIED in both directions.\nDIRECTION NOTE - the observed direction was NOT plain 'all red': with the floor removed the anonymous calls became 403, not 200, because the manage_metadata capability gate refuses a context carrying no systemPermissions. The write plane is defence-in-depth, two independent layers. That is also why the persistence case stayed green under ablation: it discriminates 'a gate that answers after the write', not the floor.\n\n=== SUITES ===\n  vitest run --project shared-showcase test/showcase-anonymous-deny-surfaces.dogfood.test.ts -> 'Test Files 1 passed (1)' / 'Tests 44 passed (44)'\n  vitest run --project isolated test/authz-conformance.test.ts (the matrix pairing gate my note touches) -> 'Test Files 1 passed (1)' / 'Tests 27 passed (27)'\n  pnpm --filter @objectstack/dogfood typecheck -> tsc --noEmit clean, exit 0\n\n=== GATES (families re-derived from the ACTUAL diff with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack', no hand-built path list) ===\n  pnpm lint (FULL REPO, eslint . --no-inline-config) -> exit 0, no findings. NOT narrowed - the full farm-owned scan was run, 130s under the lock.\n  check:nul-bytes -> 'OK (scanned 6421 text file(s) ... no raw ASCII control bytes)'\n  check:test-source-alias -> 'OK - 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/'\n  check:cross-package-test-inputs -> 'OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'\n  check:engine-double-contract -> 'OK - 390 pinned, 133 in the DEBT ledger, 2 exempt.'\n  check:where-matcher -> '288 matcher(s) discovered ... none new'\n  check:query-options-erasure, check:published-files, check:slot-lookup, check:type-source-resolution, check:type-check-coverage -> all exit 0\n  check-ci-filter-parity.mjs, check-plugin-teardown-shape.mjs, docs-audit/check-affected-docs.mjs -> all exit 0\n  spec check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs -> all exit 0\nDECLARED NARROWING: check:type-check-debt --re-measure was NOT run locally (needs the whole workspace closure built, re-runs tsc per ledger entry). CI runs it. Nothing else narrowed.\n\nNo changeset: test-only, no user-visible behaviour change. 'skip-changeset' applied via the additive labels endpoint and READ BACK after the size-labeler settled: ['size/m','skip-changeset'].",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11473 (label `finding`, unassigned): the environment-scoped `/meta` mount (`/api/v1/environments/:environmentId/meta/*`, live only when `api.enableProjectScoping` is true) is a second copy of all six write doors that no boot in the suite brings up — measured latent, not live (default boot answers ENDPOINT_NOT_FOUND), and the #8919 anti-drift enumeration's `startsWith('/api/v1/meta')` filter would not see a scoped-only door"
      ]
    }
    

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions