Skip to content

security: POST /api/v1/security/explain with userId "" silently explains the caller instead of refusing #27442

Closed

Description

@objectstack-fleet

domain:devx#1 · sunhaolin · session_017xRBcVkGJizGH7raJHhAvL

Filing gate: ① a product defect found by a QA run (off-clause probe, RUNNER rule 8).

Reproduction

  1. Boot the showcase: objectstack dev --ui --seed-admin. Sign in as admin@objectos.ai.
  2. POST /api/v1/security/explain with body {"object":"showcase_private_note","operation":"read","userId":"","recordId":"ID"}, where ID is any showcase_private_note id.
  3. Expected: a refusal (400 VALIDATION_FAILED, or the 404 RECORD_NOT_FOUND an unknown userId gets since security explain answers 200 allowed:true for a userId that matches no sys_user row, built from the synthesized baseline principal #26641).
  4. Actual: 200. The response explains the CALLER (principal = admin), not the user that was asked about.

Mechanism

security-plugin-explain-access.ts (~L136) tests if (request.userId && request.userId !== callerContext?.userId). An empty string is falsy, so the request skips the cross-user authorization branch and the sys_user existence check (~L174), and falls through to self-explain. The response does name the principal, so it does not mislead outright. But an explain asked about "nobody" answers about somebody else.

Why it matters

A client that loses the selected user (an empty picker) gets a confident answer about the wrong principal. The unknown-id case was fixed by #26641; the empty-string case is the same door's remaining gap.

QA-source: #27441 · access-security.record-access-explain · observed beside the clauses


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:access · 2026-10-29T08:11Z

    ⛔ Not a claim.

    • Read fresh. POST /api/v1/security/explain with userId: "" explains the caller instead of refusing. An admin reading the answer believes it describes someone else. From the 18.3.0 RC run ([sweep] v18.3.0 release test sweep #27377 §5); it is retested on the next wave.
    • Refuse an empty userId with 400. ⛔ An omitted userId keeps its documented meaning (the caller), and only an explicit empty string is refused.
    • bug, pm:queue.

    Generated by Claude Code

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    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_01L6bhgTYZasLoBBaTy45mgo
    Account: os-sales
    Branch: claude/project-thread-sbdkgo (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: objectstack-issue-27442 in the card thread's own container
    Domain: domain:server
    Seat: domain:server#2
    File surface: packages/rest/src/security-routes.ts (the explain route's userId read), packages/plugins/plugin-security/src/security-plugin-explain-access.ts (the explain target test), and tests (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 security explain door (this lane) | the spec schema admits an empty string and the route drops it before the schema | any admin or agent asking to explain another user's access
    Thread-read: 6491470512
    Premise: holds (read on main 2026-10-29T18:14Z: the route drops an explicit "" before ExplainRequestSchema, and the plugin's truthy test then explains the caller)
    Serial constraints cleared: none (no open PR edits either file). 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": 27442,
      "status": "done",
      "branch": "claude/project-thread-sbdkgo",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/27714",
      "session": "session_01L6bhgTYZasLoBBaTy45mgo (the card thread; the seat is session_01L6bhgUCizoYWQVJWjhdR6F)",
      "premise_still_valid": true,
      "summary": "Both PM assumptions measured true on main 5e2f3864: security-routes.ts dropped an explicit empty userId (src.userId !== '') before ExplainRequestSchema, whose userId is z.string().optional() and admits the empty string, and security-plugin-explain-access.ts then took the self-explain fold on its truthy test. The route now answers 400 VALIDATION_FAILED (existing ledgered code, the family's one refusal emitter, the same envelope as its schema refusals) when src.userId is the empty string, before any explain is computed; the userId spread became src.userId != null, so JSON null still reads as omitted (unchanged). GET counts as explicit: ?userId= names the key and the query parser hands the route the empty string (measured on the showcase boot), so GET and POST refuse alike. The engine enters the other-user arm when request.userId !== undefined, so a direct caller with an empty id gets the existing 403 PERMISSION_DENIED or, with manage_users, the 404 RECORD_NOT_FOUND an unknown id gets. Every in-tree caller of security.explain omits userId (rest-server, runtime automation, service-storage x2, plugin-webhooks, plugin-sharing, plugin-audit), so none moves. No packages/spec edit, no new code. Docs: content/docs/permissions/explain.mdx states the refusal. Head 821c2ced (fix 297d9907, origin/main 86f2afeb merged once); the card's labels and assignee were not touched.",
      "tests": "Red first: security-routes.test.ts [#27442] 2 failed | 17 passed (expected 200 to be 400); security-plugin.test.ts -t 27442 1 failed (an explanation of the caller where the refusal was expected). Green on 821c2ced: rest full suite Test Files 344 passed (344), Tests 6945 passed | 344 skipped; plugin-security full suite Test Files 237 passed (237), Tests 5119 passed | 45 skipped; dogfood explain-unknown-user.dogfood.test.ts Tests 6 passed (6), new case [empty] pins POST userId empty and GET ?userId= as 400 VALIDATION_FAILED with no verdict and no principal, and the existing [existing] case pins omitted userId = caller and a real other id answered. rest and plugin-security typecheck exit 0 (check:test-typecheck 0 errors). Ablation 1, route: anchor if (src.userId === '') replaced by a globalThis marker via scripts/ablation-replace.mjs (anchor 1 -> 0, blob dddcada6 -> 523765f7), rest rebuilt, ablation-dist-preflight found the marker in packages/rest/dist/index.js and index.cjs; unit file 2 failed | 17 passed (expected 200 to be 400), dogfood file 1 failed | 5 passed (404 RECORD_NOT_FOUND naming user '' - the engine line holding once the door's is gone); restored blob == HEAD dddcada6, git diff HEAD empty, rebuilt, preflight --absent clean and tree clean. Ablation 2, engine: request.userId !== undefined reverted to the truthy request.userId (blob 72a499a8 -> 25380898), engine block 1 failed; restored blob == HEAD 72a499a8, git diff HEAD empty.",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on 821c2ced derived 95 families; all run, exit captured before any pipe; --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. First readings that were prerequisites, rerun exit 0 after meeting them: check-plugin-teardown-shape --self-test exit 3 (pinned fixture 621a4876 fetched; 48 cases pass), spec check:docs exit 1 on the missing generated tree and check:skill-examples exit 3 (after spec gen:schema both 0), check:dual-build-cjs-loads exit 3 (after building studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge: 112 entries / 69 packages load). pnpm lint (repo-wide) VERDICT command-exit 0. PR check-runs: in_progress at report time (the PR was opened minutes ago); CI families not re-run locally.",
      "dogfood_battery": "821c2ced · os-verify-lock VERDICT command-exit 0 (held 30m45s) · Test Files 352 passed | 3 skipped (355) · Tests 2845 passed | 20 skipped (2865) · no failures",
      "mcp_calls": "3 - mcp__github__issue_read x3 (card get, comments x2); no MCP write tool",
      "api_writes": "2 - fleet-write relay pr_create (objectstack-ai/objectstack#27714, draft, no assignees, no labels, no review request; read-back 8519 bytes sent = stored); post-stamped comment on #27442 (this report). git push is not a REST write.",
      "open_questions": [],
      "out_of_scope_findings": [
        "deviation, not a finding: the PR body's attribution block and closing footer follow order-common.md section 6 (seat-2 thread link and the seat session), as on the seat's earlier card-thread PRs; this card thread's own session is named in the body prose (Acceptance notes).",
        "noted, not filed: skills/objectstack-data/rules/security.md writes the explain query as ?object=&operation=&userId= (a parameter list with empty values); a reader copying it literally now gets 400 instead of a self-explanation. It already lacked a required object value, so it was never a working request; carrier: none, noted in the PR's Skills-docs line."
      ]
    }
  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    ACCEPT → PR #27714 comment 6508012695 · head 821c2ced · 2026-10-29T20:51Z · landing sent (contract review PASS 6507706351). Follow-up: #27719 (an empty object at the same door).

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething 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