Skip to content

Security: attachment blob download has no record-level RLS — bytes served by fileId with only a committed-status check (IDOR) #2990

Description

@os-zhuang

Surfaced by the ADR-0096 E4 execution-surface verification (the one live unaudited surface). Attachment blob bytes are served under a bearer-capability model keyed only by sys_file.id — there is no record-level RLS re-check tying the download to the caller's ability to read the owning sys_attachment row or its parent record.

The hole

GET /api/v1/storage/files/:fileIdpackages/services/service-storage/src/storage-routes.ts:409-432 (and the JSON twin /files/:fileId/url, :373-396):

  • The only gate is file.status === 'committed'. No caller identity, no ExecutionContext, no sys_attachment lookup, no parent-record visibility check.
  • store.getFile(fileId) (metadata-store.ts:82-92) uses engine.findOne('sys_file', { where: { id } }). IDataEngine.findOne (packages/spec/src/contracts/data-engine.ts:30) takes no ExecutionContext — it's the raw system-level engine, so the lookup is inherently un-RLS'd.

Signed URLs are not caller-scoped either. LocalStorageAdapter.getPresignedDownload (local-storage-adapter.ts:271-278) signs a token whose payload (:48-53) is { k: key, exp, op } — storage key + expiry only, no user/tenant binding. The redeem route GET /api/v1/storage/_local/raw/:token (storage-routes.ts:468-487) verifies only HMAC + expiry, then streams raw bytes. Pure bearer capability; minting is gated by nothing more than knowing the fileId.

The parent-record link is bypassed entirely. sys_attachment (packages/platform-objects/src/audit/sys-attachment.object.ts) is the RLS-bearing polymorphic link (parent_object/parent_id/visibility/share_type) and it rides normal CRUD/RLS — but nothing in the download path consults it. Revoking a sys_attachment share does not revoke access to the bytes.

Repro

  1. User A uploads a file (fileId = v4 UUID) and attaches it to record R via a sys_attachment row. R (and thus the attachment row) is invisible to User B under RLS.
  2. B obtains the fileId via any channel that surfaces file_id without gating the blob (a later-revoked grant, an API/audit payload, a forwarded link, a denormalized field).
  3. GET /api/v1/storage/files/<fileId> → 302 → signed /_local/raw/<token>full file bytes, with zero re-check that B may read the attachment or its parent. Access survives revocation of the share.

Severity nuance

  • fileId is a v4 UUID → not brute-force enumerable. This is a capability-leak / IDOR-on-known-id, not blind enumeration — lowers likelihood, does not close the hole (the byte-access model is "possession of the id," not RLS).
  • Open sub-question (needs verification): are /api/v1/storage/* routes behind global authentication? They're registered directly on IHttpServer in storage-service-plugin.ts:204, not through RestServer.enforceAuth (which only guards /api/v1/data). If storage routes are outside the auth middleware, the exposure widens to unauthenticated callers. Either way, even a fully authenticated caller gets no record-level authorization on the bytes.

Fix direction

Before minting/redirecting, the download handlers (storage-routes.ts:373, :409) must resolve the caller's ExecutionContext and load the owning sys_attachment (and/or sys_file) through the RLS-enforcing object-CRUD layer (not raw engine.findOne), returning 404 if not visible; and/or bind the signed token to the caller identity so /_local/raw/:token cannot be replayed cross-user. Resolve the auth-gate sub-question as part of this.

Relationship to ADR-0096

E2/E3-class instance found by the E4 sweep: a surface serving data with no caller-scoped authorization. Exactly what the ADR-0096 D4 conformance matrix (a caller-scoped? proof for the storage surface) would have required. Refs: ADR-0096 (#2975 / commit e07645c), #2849, #2980 (the reports analogue).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions