Skip to content

Commit b4c2de7

Browse files
committed
fix(public-shares): size a streamed share from storage, audit every delivered response, cover both read paths
1 parent 24d8375 commit b4c2de7

3 files changed

Lines changed: 116 additions & 93 deletions

File tree

‎apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts‎

Lines changed: 76 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -800,71 +800,83 @@ describe('Public file shares against PostgreSQL and private storage', () => {
800800
'@/lib/public-shares/application'
801801
)
802802
const { encryptSecret } = await import('@/lib/core/security/encryption')
803-
for (const change of ['disabled', 'password', 'archived', 'provenance'] as const) {
804-
const f = await fixture()
805-
const { file } = await createProjectFile.execute({
806-
principal: f.principal,
807-
input: createInput(f.projectId),
808-
})
809-
const { share } = await updateProjectFileShare.execute({
810-
principal: f.principal,
811-
input: {
812-
projectId: f.projectId,
813-
fileId: file.id,
814-
isActive: true,
815-
authType: 'password',
816-
password: 'initial-password',
817-
},
818-
})
819-
const access = await authorizePublicFileShare({
820-
token: share.token,
821-
credential: { method: 'POST', password: 'initial-password' },
822-
})
823-
if (!access.authorized) throw new Error('Initial credential rejected')
824-
const applyChange = async () => {
825-
if (change === 'disabled')
826-
await db
827-
.update(publicShare)
828-
.set({ isActive: false })
829-
.where(eq(publicShare.id, share.id))
830-
if (change === 'password')
831-
await db
832-
.update(publicShare)
833-
.set({ password: (await encryptSecret('replacement-password')).encrypted })
834-
.where(eq(publicShare.id, share.id))
835-
if (change === 'provenance')
836-
await db
837-
.update(workspaceFileSecretProvenance)
838-
.set({ status: 'unknown' })
839-
.where(eq(workspaceFileSecretProvenance.fileId, file.id))
840-
if (change === 'archived')
841-
await db
842-
.update(workspaceFiles)
843-
.set({ deletedAt: new Date() })
844-
.where(eq(workspaceFiles.id, file.id))
845-
}
846-
// The change lands while the object is being read, buffered or streamed alike.
847-
const download = storage.downloadFile
848-
const downloadStream = storage.downloadFileStream
849-
const spies = [
850-
vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => {
851-
const result = await download(options)
852-
if (options.key === file.key) await applyChange()
853-
return result
854-
}),
855-
vi.spyOn(storage, 'downloadFileStream').mockImplementation(async (options) => {
856-
const result = await downloadStream(options)
857-
if (options.key === file.key) await applyChange()
858-
return result
859-
}),
860-
]
861-
await expect(
862-
sharedBytes(readPublicFileShareContent({ grant: access.grant }))
863-
).rejects.toMatchObject({
864-
code: change === 'provenance' ? 'forbidden' : 'not_found',
865-
})
866-
for (const spy of spies) spy.mockRestore()
803+
// A Markdown file streams from storage; a page is rendered whole. Both reads must refuse.
804+
const shared = {
805+
streamed: (projectId: string) => createInput(projectId),
806+
rendered: (projectId: string) => ({
807+
...createInput(projectId, '---\ntitle: Architecture\n---\n\nShared page'),
808+
name: 'Architecture',
809+
contentType: 'text/x-sim-page',
810+
}),
867811
}
812+
for (const [read, input] of Object.entries(shared))
813+
for (const change of ['disabled', 'password', 'archived', 'provenance'] as const) {
814+
const f = await fixture()
815+
const { file } = await createProjectFile.execute({
816+
principal: f.principal,
817+
input: input(f.projectId),
818+
})
819+
const { share } = await updateProjectFileShare.execute({
820+
principal: f.principal,
821+
input: {
822+
projectId: f.projectId,
823+
fileId: file.id,
824+
isActive: true,
825+
authType: 'password',
826+
password: 'initial-password',
827+
},
828+
})
829+
const access = await authorizePublicFileShare({
830+
token: share.token,
831+
credential: { method: 'POST', password: 'initial-password' },
832+
})
833+
if (!access.authorized) throw new Error('Initial credential rejected')
834+
const applyChange = async () => {
835+
if (change === 'disabled')
836+
await db
837+
.update(publicShare)
838+
.set({ isActive: false })
839+
.where(eq(publicShare.id, share.id))
840+
if (change === 'password')
841+
await db
842+
.update(publicShare)
843+
.set({ password: (await encryptSecret('replacement-password')).encrypted })
844+
.where(eq(publicShare.id, share.id))
845+
if (change === 'provenance')
846+
await db
847+
.update(workspaceFileSecretProvenance)
848+
.set({ status: 'unknown' })
849+
.where(eq(workspaceFileSecretProvenance.fileId, file.id))
850+
if (change === 'archived')
851+
await db
852+
.update(workspaceFiles)
853+
.set({ deletedAt: new Date() })
854+
.where(eq(workspaceFiles.id, file.id))
855+
}
856+
// The change lands while the object is being read, buffered or streamed alike.
857+
const download = storage.downloadFile
858+
const downloadStream = storage.downloadFileStream
859+
const spies = [
860+
vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => {
861+
const result = await download(options)
862+
if (options.key === file.key) await applyChange()
863+
return result
864+
}),
865+
vi.spyOn(storage, 'downloadFileStream').mockImplementation(async (options) => {
866+
const result = await downloadStream(options)
867+
if (options.key === file.key) await applyChange()
868+
return result
869+
}),
870+
]
871+
const delivered = readPublicFileShareContent({ grant: access.grant })
872+
await expect(sharedBytes(delivered)).rejects.toMatchObject({
873+
code: change === 'provenance' ? 'forbidden' : 'not_found',
874+
})
875+
// Each fixture took the path it names: the stream refuses on open, the page as it renders.
876+
if (read === 'streamed') expect(await delivered).toMatchObject({ kind: 'stream' })
877+
else await expect(delivered).rejects.toBeDefined()
878+
for (const spy of spies) spy.mockRestore()
879+
}
868880
}
869881
)
870882

‎apps/sim/lib/public-shares/application/read.ts‎

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,7 @@ import {
3434
import { storedObjectEtag } from '@/lib/uploads/server/delivery'
3535
import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs'
3636
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
37-
import {
38-
type ByteRange,
39-
getWorkspaceFileSize,
40-
MAX_BUFFERED_TRANSFER_BYTES,
41-
} from '@/lib/uploads/shared/types'
37+
import { type ByteRange, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
4238
import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file'
4339
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
4440
import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer'
@@ -143,6 +139,8 @@ function recordPublicShareDownload(input: {
143139
servedFile: WorkspaceFileRow
144140
operation: PublicFileReadOperation
145141
bytes: number
142+
/** The inclusive span a ranged response carried; absent for the whole file. */
143+
range?: ByteRange
146144
request?: OrchestrationRequestContext
147145
}) {
148146
const { servedFile } = input
@@ -164,6 +162,7 @@ function recordPublicShareDownload(input: {
164162
anonymous: true,
165163
owner: input.owner,
166164
bytes: input.bytes,
165+
...(input.range ? { range: `${input.range.start}-${input.range.end}` } : {}),
167166
},
168167
})
169168
}
@@ -299,30 +298,34 @@ export async function checkPublicFileShareContent(
299298
/**
300299
* A shared file served as its stored bytes, read from storage only as far as a response needs:
301300
* the whole object, or one inclusive span. The bytes are never held, so a file of any size is
302-
* served and a byte range is read on its own.
301+
* served and a byte range is read on its own. Storage is asked for the object first, so a missing
302+
* object is refused before any header is sent, and every length is measured against the object.
303303
*
304304
* `open` rechecks the grant, the revision, and the file's provenance once storage has opened and
305-
* before the stream is handed out, as a buffered read does after rendering. One view is audited
306-
* once: a download, or the first span of a player's ranged reads.
305+
* before the stream is handed out, as a buffered read does after rendering. Each delivered
306+
* response is reported and audited, with the span it carried.
307307
*/
308-
function streamSharedFile(
308+
async function streamSharedFile(
309309
grant: VerifiedPublicFileShareGrant,
310310
initial: PublicFileShareSnapshot,
311311
operation: PublicFileReadOperation,
312312
request?: OrchestrationRequestContext
313313
) {
314314
const { file } = initial
315-
const size = getWorkspaceFileSize(file)
315+
const context = initial.adapter.storageContext
316+
const stored = await headObject(file.key, context)
317+
if (!stored) throw new OrchestrationError('not_found', 'File not found')
318+
const { size } = stored
316319
return {
317320
kind: 'stream' as const,
318321
filename: file.originalName,
319322
contentType: resolveEffectiveMimeType(undefined, file.originalName),
320323
size,
321324
/** A content write uploads under a fresh key and stamps `contentUpdatedAt`. */
322-
etag: storedObjectEtag([file.key, size, file.contentUpdatedAt.getTime()]),
325+
etag: storedObjectEtag([file.key, size, file.contentUpdatedAt.getTime(), stored.version ?? '']),
323326
async open(range: ByteRange | undefined): Promise<ReadableStream<Uint8Array>> {
324327
const stream = nodeReadableToWebStream(
325-
await downloadFileStream({ key: file.key, context: initial.adapter.storageContext, range })
328+
await downloadFileStream({ key: file.key, context, range })
326329
)
327330
try {
328331
const provenance = await withPublicFileShareGrant(
@@ -338,18 +341,17 @@ function streamSharedFile(
338341
await stream.cancel(error).catch(() => undefined)
339342
throw asOrchestrationError(error) ?? error
340343
}
341-
if (!range || range.start === 0) {
342-
recordPublicShareDownload({
343-
workspaceId: initial.workspaceId,
344-
organizationId: initial.organizationId,
345-
owner: initial.owner,
346-
sharedFile: file,
347-
servedFile: file,
348-
operation,
349-
bytes: range ? range.end - range.start + 1 : size,
350-
request,
351-
})
352-
}
344+
recordPublicShareDownload({
345+
workspaceId: initial.workspaceId,
346+
organizationId: initial.organizationId,
347+
owner: initial.owner,
348+
sharedFile: file,
349+
servedFile: file,
350+
operation,
351+
bytes: range ? range.end - range.start + 1 : size,
352+
range,
353+
request,
354+
})
353355
return stream
354356
},
355357
}
@@ -374,7 +376,7 @@ export async function readPublicFileShareContent({
374376
return snapshot
375377
})
376378
if (!preview && servesStoredBytes(initial.file.contentType, initial.file.originalName)) {
377-
return streamSharedFile(grant, initial, operation, request)
379+
return await streamSharedFile(grant, initial, operation, request)
378380
}
379381
const raw = await downloadFile({
380382
key: initial.file.key,

‎apps/sim/scripts/test-project-file-public-e2e.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,18 @@ const image = await sharp({
6060

6161
async function request(
6262
path: string,
63-
options: { method?: string; body?: Record<string, unknown>; cookie?: string } = {}
63+
options: {
64+
method?: string
65+
body?: Record<string, unknown>
66+
cookie?: string
67+
headers?: Record<string, string>
68+
} = {}
6469
) {
6570
const headers = new Headers({
6671
Origin: base.origin,
6772
'Content-Type': 'application/json',
6873
'X-Forwarded-For': proofIp,
74+
...options.headers,
6975
})
7076
if (options.cookie) headers.set('Cookie', options.cookie)
7177
// boundary-raw-fetch: this proof exercises actual public binary, RSC, and credential HTTP surfaces.
@@ -281,10 +287,13 @@ try {
281287
)
282288
})
283289
await check(
284-
`${owner}: source and inline scan enforce independent real byte ceilings`,
290+
`${owner}: stored bytes stream past the buffered ceiling while the inline scan keeps its own`,
285291
async () => {
286-
await withSparseFile(required(doc.id), 100 * 1024 * 1024 + 1, async () => {
287-
assertBinaryError(await request(`${path}/content`), 413)
292+
const size = 100 * 1024 * 1024 + 1
293+
await withSparseFile(required(doc.id), size, async () => {
294+
const head = await request(`${path}/content`, { headers: { range: 'bytes=0-0' } })
295+
assert.equal(head.status, 206)
296+
assert.equal(head.headers.get('content-range'), `bytes 0-0/${size}`)
288297
assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 404)
289298
})
290299
await withSparseFile(required(photo.id), 100 * 1024 * 1024 + 1, async () => {

0 commit comments

Comments
 (0)