Summary
Two file handlers authorize an unrelated resource (an app, or a different dataset the attacker owns) and then sign or read an S3 object using a key taken directly from the request, without checking that the key belongs to the caller's team. Because S3 object keys are global within the bucket and carry the tenant id only as a path segment, an attacker can supply another team's key and obtain its file contents. The chat-file presign endpoint is reachable even by an unauthenticated public share-link visitor, yielding cross-team disclosure of uploaded chat files. The dataset preview endpoint yields cross-team disclosure of dataset file contents for any authenticated user with write access to any dataset.
Details
The correct pattern exists in the codebase, projects/app/src/pages/api/core/dataset/file/getSearchTestImagePreviewUrls.ts:23-32: it derives teamId from authDataset and then .filter((key) => isS3ObjectKey(key, 'temp') && key.startsWith(\temp/${teamId}/`))` before signing. The two handlers below omit that key-to-team binding.
- Chat file presign (unauthenticated cross-team read). projects/app/src/pages/api/core/chat/file/presignChatFileGetUrl.ts:
const { key, appId, mode, outLinkAuthData } = parseApiInput({ req, bodySchema: PresignChatFileGetUrlSchema }).body;
await authChatCrud({ req, authToken: true, authApiKey: true, appId, ...outLinkAuthData }); // checks access to appId only; never inspects key
const { url } = await getS3ChatSource().createGetChatFileURL({ key, external: true, mode }); // signs whatever key
return url;
authChatCrud only proves the caller can read appId (or holds a valid share link for it; chatId is optional, so an outLink visitor passes). It does not look at key. createGetChatFileURL -> createExternalUrl, packages/service/common/s3/buckets/base.ts:236, signs the provided key with no team/app/chat scoping (proxy mode returns /api/system/file/[jwt] where the JWT only attests the key, not ownership). The request schema is key: z.string().min(1) with no prefix constraint (packages/global/openapi/core/chat/file/api.ts). Chat keys are chat/<appId>/<uId>/<chatId>/<filename> (packages/service/common/s3/utils.ts), so any other team's chat files are reachable by key. The sibling helperBot handler does the right check (core/chat/helperBot/getFilePreviewUrl.ts parses the key and rejects on userId !== uid); this one does not.
- Dataset file preview (authenticated cross-team read). projects/app/src/pages/api/core/dataset/file/getPreviewChunks.ts (fileLocal branch): it calls authCollectionFile({ fileId: sourceId, per: OwnerPermissionVal }) on the victim key, then authDataset({ datasetId, per: Write }) on a different dataset the attacker owns, then readDatasetSourceRawText({ teamId: , sourceId: , datasetId: }). authCollectionFile, packages/service/support/permission/auth/file.ts:15-40, only checks that the S3 object exists and then returns
new Permission({ role: ReadRoleVal, isOwner: true }) unconditionally, with no team check:
if (isS3ObjectKey(fileId, 'dataset')) {
const exists = await getS3DatasetSource().isObjectExists(fileId);
if (!exists) return Promise.reject(CommonErrEnum.fileNotFound);
} else { return Promise.reject('Invalid dataset file key'); }
const permission = new Permission({ role: ReadRoleVal, isOwner: true }); // isOwner always true
The guard if (fileAuthRes.tmbId !== tmbId ...) is ineffective because fileAuthRes.tmbId is the caller's own tmbId. readDatasetSourceRawText -> getDatasetFileRawText (packages/service/common/s3/sources/dataset/index.ts) downloads by key alone. Dataset keys are dataset/<datasetId>/<filename>.
PoC
Chat files (unauthenticated, via any public share link):
POST /api/core/chat/file/presignChatFileGetUrl
{ "appId": "<an app reachable via a public share link>",
"outLinkAuthData": { "shareId": "<that share id>", "outLinkUid": "<any>" },
"key": "chat/<victimAppId>/<victimUid>/<victimChatId>/<file>" }
returns a working download URL for another team's chat file.
Dataset files (authenticated user with Write on any dataset):
POST /api/core/dataset/file/getPreviewChunks
{ "type": "fileLocal", "datasetId": "<attacker-owned dataset, Write>", "sourceId": "dataset/<victimDatasetId>/<file>" }
returns parsed raw text/chunks of the victim team's file.
Live-validated (chat-file path): the verbatim createExternalUrl proxy-mode logic (jwtSignS3DownloadToken over { objectKey } with HS256) was executed with a victim chat-file key, and the /api/system/file/[jwt] proxy verification (signature-only) was run against the resulting token. Captured:
[001] minted download token for VICTIM key: chat/victimApp/victimUid/victimChat/secret_contract.pdf
proxy /api/system/file/[jwt] verified -> {"ok":true,"objectKey":"chat/victimApp/victimUid/victimChat/secret_contract.pdf"}
CROSS-TEAM FILE: CONFIRMED (valid signed download URL issued+accepted for another team's file key, no key->team check)
A valid download token was issued and accepted for another team's file key, with no key-to-team binding (the proxy attests only the signature, not ownership). authChatCrud checking only appId is verified at source. (The signing secret is a stand-in; the sign-any-key + verify-signature-only logic is verbatim.) The chat-file path requires only knowledge of the target key; the dataset path additionally requires guessing the dataset key (datasetId is a 24-char ObjectId plus the uploaded filename), which bounds its practical reach.
Impact
Cross-team disclosure of uploaded files. The chat-file presign endpoint requires no authentication beyond a public share link to any one app, so any tenant's chat-uploaded files are readable given their key. The dataset-preview endpoint discloses any team's dataset file contents to an authenticated user holding write access to any dataset, given the file key. Uploaded files commonly contain sensitive documents.
Remediation
Bind the object key to the authorized tenant before signing or reading, as getSearchTestImagePreviewUrls already does. In presignChatFileGetUrl, parse key and require it to match the authorized app and the caller's uId (chat///...), rejecting otherwise. In authCollectionFile, look up the file's owning team and verify it equals the caller's teamId (do not return isOwner:true unconditionally), and have getDatasetFileRawText/readDatasetSourceRawText assert the key is under dataset// for a dataset the caller is authorized on. More generally, every presign/read should validate the key prefix against the caller's teamId.
Summary
Two file handlers authorize an unrelated resource (an app, or a different dataset the attacker owns) and then sign or read an S3 object using a key taken directly from the request, without checking that the key belongs to the caller's team. Because S3 object keys are global within the bucket and carry the tenant id only as a path segment, an attacker can supply another team's key and obtain its file contents. The chat-file presign endpoint is reachable even by an unauthenticated public share-link visitor, yielding cross-team disclosure of uploaded chat files. The dataset preview endpoint yields cross-team disclosure of dataset file contents for any authenticated user with write access to any dataset.
Details
The correct pattern exists in the codebase, projects/app/src/pages/api/core/dataset/file/getSearchTestImagePreviewUrls.ts:23-32: it derives teamId from authDataset and then
.filter((key) => isS3ObjectKey(key, 'temp') && key.startsWith(\temp/${teamId}/`))` before signing. The two handlers below omit that key-to-team binding.authChatCrud only proves the caller can read appId (or holds a valid share link for it; chatId is optional, so an outLink visitor passes). It does not look at key. createGetChatFileURL -> createExternalUrl, packages/service/common/s3/buckets/base.ts:236, signs the provided key with no team/app/chat scoping (proxy mode returns /api/system/file/[jwt] where the JWT only attests the key, not ownership). The request schema is
key: z.string().min(1)with no prefix constraint (packages/global/openapi/core/chat/file/api.ts). Chat keys arechat/<appId>/<uId>/<chatId>/<filename>(packages/service/common/s3/utils.ts), so any other team's chat files are reachable by key. The sibling helperBot handler does the right check (core/chat/helperBot/getFilePreviewUrl.ts parses the key and rejects on userId !== uid); this one does not.new Permission({ role: ReadRoleVal, isOwner: true })unconditionally, with no team check:The guard
if (fileAuthRes.tmbId !== tmbId ...)is ineffective because fileAuthRes.tmbId is the caller's own tmbId. readDatasetSourceRawText -> getDatasetFileRawText (packages/service/common/s3/sources/dataset/index.ts) downloads by key alone. Dataset keys aredataset/<datasetId>/<filename>.PoC
Chat files (unauthenticated, via any public share link):
returns a working download URL for another team's chat file.
Dataset files (authenticated user with Write on any dataset):
returns parsed raw text/chunks of the victim team's file.
Live-validated (chat-file path): the verbatim createExternalUrl proxy-mode logic (jwtSignS3DownloadToken over { objectKey } with HS256) was executed with a victim chat-file key, and the /api/system/file/[jwt] proxy verification (signature-only) was run against the resulting token. Captured:
A valid download token was issued and accepted for another team's file key, with no key-to-team binding (the proxy attests only the signature, not ownership). authChatCrud checking only appId is verified at source. (The signing secret is a stand-in; the sign-any-key + verify-signature-only logic is verbatim.) The chat-file path requires only knowledge of the target key; the dataset path additionally requires guessing the dataset key (datasetId is a 24-char ObjectId plus the uploaded filename), which bounds its practical reach.
Impact
Cross-team disclosure of uploaded files. The chat-file presign endpoint requires no authentication beyond a public share link to any one app, so any tenant's chat-uploaded files are readable given their key. The dataset-preview endpoint discloses any team's dataset file contents to an authenticated user holding write access to any dataset, given the file key. Uploaded files commonly contain sensitive documents.
Remediation
Bind the object key to the authorized tenant before signing or reading, as getSearchTestImagePreviewUrls already does. In presignChatFileGetUrl, parse key and require it to match the authorized app and the caller's uId (chat///...), rejecting otherwise. In authCollectionFile, look up the file's owning team and verify it equals the caller's teamId (do not return isOwner:true unconditionally), and have getDatasetFileRawText/readDatasetSourceRawText assert the key is under dataset// for a dataset the caller is authorized on. More generally, every presign/read should validate the key prefix against the caller's teamId.