diff --git a/.changeset/22431-unclaimed-download-signed-in.md b/.changeset/22431-unclaimed-download-signed-in.md new file mode 100644 index 00000000000..b29ac6dc16d --- /dev/null +++ b/.changeset/22431-unclaimed-download-signed-in.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-storage': minor +--- + +fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default. + +- **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller. +- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `` and `` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log. + +What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row. diff --git a/content/docs/permissions/attachments-access.mdx b/content/docs/permissions/attachments-access.mdx index 770d9119cb0..d65734bdbd2 100644 --- a/content/docs/permissions/attachments-access.mdx +++ b/content/docs/permissions/attachments-access.mdx @@ -111,7 +111,7 @@ record, and issue a **short-lived signed URL**: | Code | Status | When | | --- | --- | --- | -| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file, or a credential the organization wall refuses (see below) | +| `AUTH_REQUIRED` | 401 | Anonymous download of any file not marked `acl: 'public_read'` (see below), or a credential the organization wall refuses | | `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to | **The 401 is the generic "unauthenticated" answer, and it has always covered @@ -132,10 +132,15 @@ anonymous case does: the response is byte-identical to sending no credential at all, and the reason is written to the server log instead. The check itself lives in the shared API-key admission path, not in the attachments gate. -The gate is scoped to attachments files on purpose: **non-attachments files** -(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous -capability URL, because they are embedded in `` which cannot carry a -bearer token. Their discovery is already gated by access to the owning record. +The parent-record check applies to files that have a parent: attachments-scope +files, and files a record's `file` / `image` field owns, which are judged against +that one record (`FILE_DOWNLOAD_DENIED`, 403, when it cannot be read). A file with +**neither** — an upload no record has claimed, such as an avatar or an +organization logo stored as a URL — has no parent to check, so its download +requires only a signed-in caller (`AUTH_REQUIRED`, 401, otherwise). A browser's +`` sends the session cookie set at sign-in, so a signed-in page keeps +rendering these files. Only a file marked `acl: 'public_read'` is served to a +caller with no session: mark a file that way when it must render before sign-in. The upload entry points (presigned / chunked) likewise require a session when an auth service is wired, and stamp `owner_id` on the new `sys_file`. @@ -174,7 +179,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS | Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) | | List / read | inherits parent read visibility | *(filtered out)* | | Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held | -| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) | +| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) | ## See also diff --git a/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts new file mode 100644 index 00000000000..e4f947c8049 --- /dev/null +++ b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22431] A file with neither an attachments scope nor a field owner — an +// upload no record has claimed, which is how an avatar or an organization logo +// stored as a URL lives — needs a signed-in caller at both download doors, +// over a REAL showcase boot. `acl: 'public_read'` stays the one anonymous +// download (ADR-0104). +// +// The package suite (`service-storage/src/storage-routes.test.ts`) pins the +// gate over a hand-wired resolver. This file is where the composed one runs: +// the plugin's own `kernel:ready` mount binds the kernel's `auth` service as +// the resolver, so the answer a caller gets here is the deployment's answer. +// +// The half that decides whether the change is safe to ship is the COOKIE case. +// A browser renders these files through `` / ``, which can +// carry no bearer header — only the session cookie the sign-in set. So the +// signed-in reader is asserted twice: once with the bearer a script sends, +// once with nothing but that cookie, and both must reach the bytes. +// +// Not eligible for the shared showcase project: it boots its own plugins. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const BYTES = 'unclaimed'; + +/** Strip the origin off an absolute adapter URL so it can be re-injected. */ +const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, ''); + +describe('[#22431] a download of a file with no attachments scope and no field owner needs a signed-in caller', () => { + let stack: VerifyStack; + let rootDir: string; + let ql: any; + let token: string; + let cookie: string; + /** Uploaded with no scope named — the shape the console's upload adapter sends. */ + let unclaimed: string; + let attached: string; + + const bearer = () => ({ Authorization: `Bearer ${token}` }); + + /** The real three-step presigned upload; `scope` omitted unless named. */ + const upload = async (name: string, scope?: string): Promise => { + const presign = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...bearer() }, + body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: BYTES.length, ...(scope ? { scope } : {}) }), + }); + expect(presign.status, 'presign').toBe(200); + const { data } = (await presign.json()) as any; + const put = await stack.raw(toPath(String(data.uploadUrl)), { + method: 'PUT', + headers: data.headers ?? { 'content-type': 'text/plain' }, + body: BYTES, + }); + expect(put.status, 'raw PUT').toBeLessThan(300); + const complete = await stack.api('/storage/upload/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...bearer() }, + body: JSON.stringify({ fileId: data.fileId }), + }); + expect(complete.status, 'complete').toBe(200); + return String(data.fileId); + }; + + /** Both doors for one caller: the JSON door and the redirect door. */ + const doors = async (fileId: string, headers: Record = {}) => ({ + url: await stack.api(`/storage/files/${fileId}/url`, { headers }), + redirect: await stack.api(`/storage/files/${fileId}`, { headers, redirect: 'manual' } as RequestInit), + }); + + const expectRefused = async (res: Response, label: string) => { + expect(res.status, label).toBe(401); + const body = (await res.json()) as any; + expect(body.success, label).toBe(false); + expect(body.error?.code, label).toBe('AUTH_REQUIRED'); + }; + + /** A 302 whose target, followed with NO credential, serves the uploaded bytes. */ + const expectBytesBehindRedirect = async (res: Response, label: string) => { + expect(res.status, label).toBe(302); + const location = res.headers.get('location'); + expect(location, `${label}: a 302 with no Location`).toBeTruthy(); + const bytes = await stack.raw(toPath(String(location))); + expect(bytes.status, label).toBe(200); + expect(await bytes.text(), label).toBe(BYTES); + }; + + beforeAll(async () => { + rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-')); + stack = await bootStack(showcaseStack, { + security: showcaseAppDefaultSecurity(), + extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })], + }); + ql = await stack.kernel.getServiceAsync('objectql'); + token = await stack.signIn(); + + // The browser transport: the session cookie the sign-in response sets. + const signIn = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + expect(signIn.status).toBe(200); + cookie = signIn.headers + .getSetCookie() + .map((c) => c.split(';')[0]) + .filter((pair) => pair.includes('session_token=')) + .join('; '); + expect(cookie, 'the sign-in sets a session cookie').toContain('session_token='); + + unclaimed = await upload('unclaimed.txt'); + attached = await upload('attached.txt', 'attachments'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (rootDir) await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it('the file under test is unclaimed: no attachments scope, no field owner, not public_read', async () => { + const row = await ql.findOne('sys_file', { where: { id: unclaimed }, context: SYS }); + expect(row?.scope).not.toBe('attachments'); + expect(row?.ref_object ?? null).toBeNull(); + expect(row?.acl ?? 'private').not.toBe('public_read'); + }); + + it('an anonymous caller is refused 401 AUTH_REQUIRED at both download doors', async () => { + const { url, redirect } = await doors(unclaimed); + await expectRefused(url, 'the URL door'); + await expectRefused(redirect, 'the redirect door'); + expect(redirect.headers.get('location'), 'no capability URL leaks on the refusal').toBeNull(); + }); + + it('a signed-in caller with a bearer token is served as before', async () => { + const { url, redirect } = await doors(unclaimed, bearer()); + expect(url.status).toBe(200); + const body = (await url.json()) as any; + const bytes = await stack.raw(toPath(String(body.data.url))); + expect(await bytes.text()).toBe(BYTES); + await expectBytesBehindRedirect(redirect, 'bearer, redirect door'); + }); + + it('a signed-in browser is served through its session cookie alone — what carries', async () => { + const { url, redirect } = await doors(unclaimed, { cookie }); + expect(url.status, 'the URL door, cookie only').toBe(200); + await expectBytesBehindRedirect(redirect, 'cookie only, redirect door'); + }); + + it("acl: 'public_read' keeps the file anonymous, and only that declaration does", async () => { + await ql.update('sys_file', { acl: 'public_read' }, { where: { id: unclaimed }, context: SYS }); + try { + const { url, redirect } = await doors(unclaimed); + expect(url.status, 'public_read, anonymous URL door').toBe(200); + await expectBytesBehindRedirect(redirect, 'public_read, anonymous redirect door'); + } finally { + await ql.update('sys_file', { acl: 'private' }, { where: { id: unclaimed }, context: SYS }); + } + await expectRefused((await doors(unclaimed)).redirect, 'back to private'); + }); + + it('controls: an anonymous upload and an anonymous attachments-scope download stay refused', async () => { + const presign = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: 'anon.txt', mimeType: 'text/plain', size: 1 }), + }); + await expectRefused(presign, 'anonymous upload'); + const { url, redirect } = await doors(attached); + await expectRefused(url, 'attachments-scope, URL door'); + await expectRefused(redirect, 'attachments-scope, redirect door'); + }); +}); diff --git a/packages/services/service-storage/src/error-envelope.conformance.test.ts b/packages/services/service-storage/src/error-envelope.conformance.test.ts index 6817337e344..44f4f38f327 100644 --- a/packages/services/service-storage/src/error-envelope.conformance.test.ts +++ b/packages/services/service-storage/src/error-envelope.conformance.test.ts @@ -314,6 +314,19 @@ describe('storage error envelope (#3675)', () => { return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a2' } }); }, }, + { + // #22431: a file with neither an attachments scope nor a field owner + // needs a signed-in caller — the same pair as the two 401s above. + name: 'anonymous download of a file with neither an attachments scope nor a field owner', + status: 401, + code: 'AUTH_REQUIRED', + run: async () => { + const store = new StorageMetadataStore(null); + await committedAttachment(store, 'u1', { scope: 'user', key: 'user/u1.png' }); + const routes = mount(await tmpAdapter(), store, { resolveSession: async () => null }); + return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'u1' } }); + }, + }, { name: 'raw upload against an adapter with no token support', status: 501, diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts index e413ad83fe2..f2e844ec8ed 100644 --- a/packages/services/service-storage/src/storage-routes.test.ts +++ b/packages/services/service-storage/src/storage-routes.test.ts @@ -563,6 +563,155 @@ describe('Storage REST Routes', () => { }); }); + // ── [#22431] The unclaimed class: neither an attachments scope nor a field + // owner. It has no parent record to derive access from, so the download + // asks for one thing only — a signed-in caller — unless the file declares + // `acl: 'public_read'` (ADR-0104's one anonymous download). + describe('a file with neither an attachments scope nor a field owner needs a signed-in caller (#22431)', () => { + const DOORS = ['/api/v1/storage/files/:fileId/url', '/api/v1/storage/files/:fileId'] as const; + + const commit = async (s: StorageMetadataStore, rec: Partial) => + s.createFile({ + id: rec.id ?? 'u-dl', + key: rec.key ?? `user/${rec.id ?? 'u-dl'}.png`, + name: 'x.png', + status: 'committed', + acl: 'private', + scope: 'user', + ...rec, + } as any); + + function serverWith(resolveSession: any, extra: any = {}) { + const server = createMockHttpServer(); + const s = new StorageMetadataStore(null); + const authorizeFileRead = vi.fn(async () => 'deny' as const); + const logger = { info: vi.fn(), warn: vi.fn() }; + registerStorageRoutes(server as any, adapter, s, { + basePath: '/api/v1/storage', + resolveSession, + authorizeFileRead, + logger, + downloadTtl: 120, + ...extra, + }); + return { server, store: s, authorizeFileRead, logger }; + } + + const hit = async (server: any, path: string, fileId: string) => { + const res = createMockRes(); + await server._getHandler('GET', path)!(createMockReq({ params: { fileId } }), res); + return res; + }; + + /** Seconds of life the minted local capability URL carries. */ + const ttlOf = (url: string) => + adapter.verifyToken(url.split('/').pop()!, 'get').exp - Math.floor(Date.now() / 1000); + + it('refuses an anonymous caller 401 AUTH_REQUIRED at both download doors, minting no URL', async () => { + const resolveSession = vi.fn(async () => null); + const { server, store: s, authorizeFileRead } = serverWith(resolveSession); + await commit(s, { id: 'u1' }); + const minted = vi.spyOn(adapter, 'getPresignedDownload'); + for (const door of DOORS) { + const res = await hit(server, door, 'u1'); + expect(res._status, door).toBe(401); + expect(res._json?.success, door).toBe(false); + expect(res._json?.error?.code, door).toBe('AUTH_REQUIRED'); + expect(res._headers.Location, door).toBeUndefined(); + } + expect(minted).not.toHaveBeenCalled(); + expect(resolveSession).toHaveBeenCalledTimes(2); + // Not the parent-governed authorizer's file: there is no parent record. + expect(authorizeFileRead).not.toHaveBeenCalled(); + minted.mockRestore(); + }); + + it('answers the same code and status the upload doors give an anonymous caller', async () => { + const { server, store: s } = serverWith(async () => null); + await commit(s, { id: 'u2' }); + const download = await hit(server, DOORS[0], 'u2'); + const upload = createMockRes(); + await server._getHandler('POST', '/api/v1/storage/upload/presigned')!( + createMockReq({ body: { filename: 'a.png', mimeType: 'image/png', size: 3 } }), + upload, + ); + expect([download._status, download._json?.error?.code]).toEqual([upload._status, upload._json?.error?.code]); + }); + + it('fails closed when the session resolver throws, or resolves a session with no user', async () => { + for (const resolveSession of [ + async () => { throw new Error('auth backend down'); }, + async () => ({ organizationId: 'org-1' }), + ]) { + const { server, store: s } = serverWith(resolveSession); + await commit(s, { id: 'u3' }); + for (const door of DOORS) { + const res = await hit(server, door, 'u3'); + expect(res._status, door).toBe(401); + expect(res._json?.error?.code, door).toBe('AUTH_REQUIRED'); + } + } + }); + + it('serves a signed-in caller as before: a 302 and a URL carrying the presigned TTL', async () => { + const { server, store: s, authorizeFileRead } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800 }); + await commit(s, { id: 'u4' }); + const url = await hit(server, DOORS[0], 'u4'); + expect(url._status).toBe(200); + expect(url._json.data.url).toContain('/_local/raw/'); + const ttl = ttlOf(url._json.data.url); + expect(ttl).toBeGreaterThan(1700); + expect(ttl).toBeLessThanOrEqual(1800); + const redirect = await hit(server, DOORS[1], 'u4'); + expect(redirect._status).toBe(302); + expect(redirect._headers.Location).toContain('/_local/raw/'); + expect(authorizeFileRead).not.toHaveBeenCalled(); + }); + + it("keeps an acl: 'public_read' file anonymous without asking for a session", async () => { + const resolveSession = vi.fn(async () => null); + const { server, store: s } = serverWith(resolveSession); + await commit(s, { id: 'u5', acl: 'public_read' }); + expect((await hit(server, DOORS[0], 'u5'))._status).toBe(200); + expect((await hit(server, DOORS[1], 'u5'))._status).toBe(302); + expect(resolveSession).not.toHaveBeenCalled(); + }); + + it('leaves the parent-governed classes to the authorizer: attachments-scope and field-owned verdicts are unchanged', async () => { + const resolveSession = vi.fn(async () => ({ userId: 'user-7' })); + const { server, store: s, authorizeFileRead } = serverWith(resolveSession); + await commit(s, { id: 'g1', scope: 'attachments', key: 'attachments/g1.bin' }); + await commit(s, { id: 'g2', ref_object: 'product', ref_id: 'p1', ref_field: 'image' }); + const attached = await hit(server, DOORS[0], 'g1'); + expect(attached._status).toBe(403); + expect(attached._json?.error?.code).toBe('ATTACHMENT_DOWNLOAD_DENIED'); + const owned = await hit(server, DOORS[0], 'g2'); + expect(owned._status).toBe(403); + expect(owned._json?.error?.code).toBe('FILE_DOWNLOAD_DENIED'); + expect(authorizeFileRead).toHaveBeenCalledTimes(2); + expect(resolveSession).not.toHaveBeenCalled(); + }); + + it('answers a missing file 404 before the session is asked', async () => { + const resolveSession = vi.fn(async () => null); + const { server } = serverWith(resolveSession); + const res = await hit(server, DOORS[0], 'missing'); + expect(res._status).toBe(404); + expect(res._json?.error?.code).toBe('FILE_NOT_FOUND'); + expect(resolveSession).not.toHaveBeenCalled(); + }); + + it('keeps a kernel with no session resolver open, and says so once', async () => { + const { server, store: s, logger } = serverWith(undefined); + await commit(s, { id: 'u6' }); + expect((await hit(server, DOORS[1], 'u6'))._status).toBe(302); + expect((await hit(server, DOORS[0], 'u6'))._status).toBe(200); + const notices = logger.info.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('no session resolver wired')); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain('download'); + }); + }); + describe('PUT/GET /_local/raw/:token', () => { it('should accept raw upload with valid token and serve download', async () => { // Generate a presigned upload @@ -644,14 +793,16 @@ describe('Storage REST Routes', () => { expect(file?.owner_id).toBe('user-42'); }); - it('download routes stay open even with a resolver wired (capability URLs)', async () => { + it('download routes are not behind the upload gate: an anonymous read of a missing file is a 404', async () => { const server = registerWithResolver(async () => null); const res = createMockRes(); await server._getHandler('GET', '/api/v1/storage/files/:fileId/url')!( createMockReq({ params: { fileId: 'missing' } }), res, ); - expect(res._status).toBe(404); // not 401 — anonymous reads reach the handler + // Not 401: the download doors look the file up first and judge the + // caller per file class (#22431 — see the unclaimed-class block above). + expect(res._status).toBe(404); }); it('stays open (back-compat) when no resolver is wired', async () => { diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 5b51e31c872..f73222e1b85 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -221,9 +221,19 @@ export interface StorageRoutesOptions { * progress doors also refuse a caller who is not the file's uploader * (`isFileUploader`, `403 PERMISSION_DENIED`). When absent (bare kernels, * tests), the routes stay open — back-compat, logged once — and there is no - * caller identity for the ownership rule to compare. Download routes are NOT - * gated here (capability URLs embedded in /; gating them - * is a tracked follow-up needing cookie sessions or signed links). + * caller identity for the ownership rule to compare. + * + * [#22431] The two DOWNLOAD routes ask it too, for one file class: a file + * with neither an attachments scope nor a field owner (an upload no record + * has claimed — an avatar or an organization logo stored as a URL, a pick + * not yet saved, a released file). Such a file has no parent record to + * derive access from, so the one thing its download requires is a + * signed-in caller; none ⇒ `401 AUTH_REQUIRED`, the answer the upload + * routes give. A file marked `acl: 'public_read'` stays anonymous + * (ADR-0104). A browser's `` / `` reaches this resolver + * with the session cookie its sign-in set, which reads the same as a bearer + * header, so a signed-in page keeps rendering these files. Absent: these + * downloads stay open as before — logged once. */ resolveSession?: (req: IHttpRequest) => Promise; /** @@ -241,11 +251,14 @@ export interface StorageRoutesOptions { * `SERVICE_UNAVAILABLE` rather than flattened into the `deny` 403 — the * store was unreadable, so no verdict was ever reached. Every other throw * still fails closed to `deny`. - * A file with neither an attachments scope nor a field owner — an unclaimed - * upload, an org logo — keeps the stable anonymous capability URL, as does - * any file explicitly marked `acl: 'public_read'` (the opt-in for genuinely - * public embedding, since `` cannot carry a bearer token). - * When absent (bare kernels, tests), all downloads stay open (back-compat). + * A file with neither an attachments scope nor a field owner is not this + * authorizer's to judge — it has no parent record — and needs only a + * signed-in caller ({@link StorageRoutesOptions.resolveSession}, #22431). + * Any file explicitly marked `acl: 'public_read'` keeps the stable + * anonymous capability URL (ADR-0104: the opt-in for genuinely public + * embedding, which no sign-in precedes). + * When absent (bare kernels, tests), parent-governed downloads stay open + * (back-compat). */ authorizeFileRead?: (file: FileRecord, req: IHttpRequest) => Promise; /** @@ -338,19 +351,59 @@ export function registerStorageRoutes( return false; }; + // ── Download session gate (#22431) ─────────────────────────────────── + // The one refusal both download doors give a caller with no session — + // for a file of the unclaimed class below, and for a parent-governed file + // whose authorizer answers `unauthenticated`. `401` / `AUTH_REQUIRED`, the + // pair the upload gate answers too, so a client branches on one answer. + const refuseAnonymousDownload = (res: IHttpResponse): false => { + sendError(res, 401, 'AUTH_REQUIRED', 'Authentication required to download this file'); + return false; + }; + + // `true` ⇒ the caller is signed in, or no resolver is wired (bare kernels, + // tests: open, as before — said once, here, because the upload gate's own + // notice names only the upload routes). `false` ⇒ the 401 was already sent. + // A resolver that throws fails closed, exactly as the upload gate does. + let warnedOpenDownloads = false; + const requireDownloadSession = async (req: IHttpRequest, res: IHttpResponse): Promise => { + if (!opts.resolveSession) { + if (!warnedOpenDownloads) { + warnedOpenDownloads = true; + opts.logger?.info( + '[storage] no session resolver wired — a file with neither an attachments scope nor a field owner ' + + 'downloads without a signed-in caller (bare-kernel mode)', + ); + } + return true; + } + let session: StorageUploadSession | null | undefined; + try { + session = await opts.resolveSession(req); + } catch { + session = null; + } + return session?.userId ? true : refuseAnonymousDownload(res); + }; + // ── Download authorization gate (#2970 item 2, ADR-0104 D3 wave 2) ─── // Two kinds of file are gated, both deriving access from a PARENT record: // - `attachments`-scope files, via their sys_attachment join rows; // - field-owned files, via the single record whose field owns them // (`ref_object`/`ref_id`, ADR-0104 D3 wave 2). - // `acl: 'public_read'` opts a file back out to the stable anonymous - // capability URL — needed for genuinely public embedding (`` - // cannot carry a bearer token), and now an explicit declaration rather - // than the silent default it used to be for every field file. + // [#22431] Every OTHER file — neither an attachments scope nor a field + // owner: an upload no record has claimed — has no parent record to derive + // access from, and needs a signed-in caller instead + // (`requireDownloadSession` below). It used to be the one class still + // served as an anonymous capability URL by default. + // `acl: 'public_read'` opts any file back out to the stable anonymous + // capability URL — the explicit declaration for genuinely public + // embedding, which no sign-in precedes (ADR-0104). A signed-in browser + // needs no such opt-out: its `` carries the session cookie. // // Dual-mode safe: a legacy field holds an inline blob or an external URL, // never a `sys_file` id, so no legacy file has `ref_object` set and none of - // them start being gated by this change. + // them start being gated by the parent-derived arm. // // Returns the signed-URL TTL to use, or `false` if a response was already // sent (401/403, and since #15999 the `503 SERVICE_UNAVAILABLE` an @@ -360,9 +413,14 @@ export function registerStorageRoutes( req: IHttpRequest, res: IHttpResponse, ): Promise => { + if (file.acl === 'public_read') return currentLimits().presignedTtl; const fieldOwned = !!file.ref_object && file.ref_id != null && file.ref_id !== ''; const gated = file.scope === 'attachments' || fieldOwned; - if (!gated || file.acl === 'public_read' || !opts.authorizeFileRead) { + if (!gated) { + if (!(await requireDownloadSession(req, res))) return false; + return currentLimits().presignedTtl; + } + if (!opts.authorizeFileRead) { return currentLimits().presignedTtl; } let verdict: FileReadVerdict; @@ -399,10 +457,7 @@ export function registerStorageRoutes( } verdict = 'deny'; // a failed authz check must never fall open } - if (verdict === 'unauthenticated') { - sendError(res, 401, 'AUTH_REQUIRED', 'Authentication required to download this file'); - return false; - } + if (verdict === 'unauthenticated') return refuseAnonymousDownload(res); if (verdict === 'deny') { if (fieldOwned) { sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to the record this file belongs to'); @@ -1248,7 +1303,10 @@ export function registerStorageRoutes( // - serves the bytes directly when followed // The `/url` endpoint above returns JSON. This sibling endpoint resolves // to the same short-lived signed URL and 302-redirects so it can be used - // verbatim in any browser context. + // verbatim in any browser context. It answers exactly as `/url` does + // (`authorizeDownload`): a browser following it is judged by the session + // cookie it carries, and only an `acl: 'public_read'` file is served to + // a caller with none (#22431). // --------------------------------------------------------------------------- httpServer.get(`${basePath}/files/:fileId`, async (req: IHttpRequest, res: IHttpResponse) => { try {