Skip to content

Commit efbb4f5

Browse files
authored
fix(uploads): sign S3-compatible upload metadata as headers and allow S3_ENDPOINT in CSP (#8449)
* fix(uploads): sign S3-compatible upload metadata as headers and allow S3_ENDPOINT in CSP * test(csp): pin S3_FORCE_PATH_STYLE unset in the CSP test env
1 parent 460c01f commit efbb4f5

6 files changed

Lines changed: 185 additions & 37 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/object-storage.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,8 @@ Sim works with any S3-compatible store by pointing the S3 client at a custom end
416416
**The endpoint must be reachable from your users' browsers, and the bucket needs CORS.** Uploads use presigned `PUT` requests sent **directly from the browser** to `S3_ENDPOINT` (downloads are proxied back through the app, so they only need server-side reachability). This means:
417417

418418
- A purely internal endpoint (e.g. `https://minio.internal:9000` that only the app pods can resolve) will let the server start cleanly but **uploads will fail in the browser**. Use an endpoint your users can reach.
419-
- Configure a **CORS policy** on the bucket that allows your Sim origin (`PUT`, `GET`, and the `Authorization` / `Content-Type` / `x-amz-*` headers). This applies to AWS S3 too — R2 and MinIO are no different.
419+
- Configure a **CORS policy** on the bucket that allows your Sim origin with `PUT` and `GET`, and `AllowedHeaders: ["*"]`. With a custom endpoint, the browser sends the upload's metadata as signed `x-amz-meta-*` headers alongside `Content-Type` and `If-None-Match`, because many S3-compatible stores ignore metadata passed in the URL. If you list headers individually, include all of them. MinIO applies its own server-wide CORS and needs no bucket rule.
420+
- Sim adds the `S3_ENDPOINT` origin (and its bucket subdomains, unless `S3_FORCE_PATH_STYLE` is set) to its Content Security Policy, so the browser may upload there.
420421
</Callout>
421422

422423
<Tabs items={['Cloudflare R2', 'MinIO', 'RustFS']}>

‎apps/sim/lib/core/security/csp.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
22

33
await vi.hoisted(async () => {
44
const { setEnv } = await import('@sim/testing/mocks/env.mock')
@@ -14,9 +14,12 @@ await vi.hoisted(async () => {
1414
NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
1515
NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
1616
NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
17+
S3_ENDPOINT: 'https://s3.de.io.cloud.ovh.net',
18+
S3_FORCE_PATH_STYLE: undefined,
1719
})
1820
})
1921

22+
import { setEnv } from '@sim/testing/mocks/env.mock'
2023
import { buildCSPString, generateRuntimeCSP, getChatEmbedCSPPolicy, getMainCSPPolicy } from './csp'
2124

2225
describe('buildCSPString', () => {
@@ -32,7 +35,18 @@ describe('buildCSPString', () => {
3235
})
3336
})
3437

38+
function connectSources(policy: string): string[] {
39+
const directive = policy.split('; ').find((d) => d.startsWith('connect-src ')) ?? ''
40+
return directive.split(' ').slice(1)
41+
}
42+
3543
describe('getMainCSPPolicy', () => {
44+
it('allows direct uploads to the build-time S3_ENDPOINT', () => {
45+
const sources = connectSources(getMainCSPPolicy())
46+
expect(sources).toContain('https://s3.de.io.cloud.ovh.net')
47+
expect(sources).toContain('https://*.s3.de.io.cloud.ovh.net')
48+
})
49+
3650
it('keeps the restrictive security directives', () => {
3751
const policy = getMainCSPPolicy()
3852

@@ -63,6 +77,40 @@ describe('generateRuntimeCSP', () => {
6377
})
6478
})
6579

80+
describe('generateRuntimeCSP S3_ENDPOINT sources', () => {
81+
afterEach(() => {
82+
setEnv({ S3_ENDPOINT: 'https://s3.de.io.cloud.ovh.net', S3_FORCE_PATH_STYLE: undefined })
83+
})
84+
85+
it('allows the endpoint and its bucket subdomains for virtual-hosted addressing', () => {
86+
setEnv({ S3_ENDPOINT: 'https://acct.r2.cloudflarestorage.com/' })
87+
const sources = connectSources(generateRuntimeCSP())
88+
expect(sources).toContain('https://acct.r2.cloudflarestorage.com')
89+
expect(sources).toContain('https://*.acct.r2.cloudflarestorage.com')
90+
})
91+
92+
it('keeps a non-default port and drops the bucket wildcard under S3_FORCE_PATH_STYLE', () => {
93+
setEnv({ S3_ENDPOINT: 'https://minio.example.com:9000', S3_FORCE_PATH_STYLE: 'true' })
94+
const sources = connectSources(generateRuntimeCSP())
95+
expect(sources).toContain('https://minio.example.com:9000')
96+
expect(sources.some((s) => s.includes('*.minio.example.com'))).toBe(false)
97+
})
98+
99+
it('does not build a wildcard over an IP endpoint, which is always path-style', () => {
100+
setEnv({ S3_ENDPOINT: 'http://10.0.0.5:9000' })
101+
const sources = connectSources(generateRuntimeCSP())
102+
expect(sources).toContain('http://10.0.0.5:9000')
103+
expect(sources.some((s) => s.includes('*.10.0.0.5'))).toBe(false)
104+
})
105+
106+
it('ignores an endpoint without an http(s) scheme instead of emitting a broken source', () => {
107+
setEnv({ S3_ENDPOINT: 'minio.example.com:9000' })
108+
const csp = generateRuntimeCSP()
109+
expect(csp).not.toContain('minio.example.com')
110+
expect(connectSources(csp)).toContain("'self'")
111+
})
112+
})
113+
66114
describe('getChatEmbedCSPPolicy', () => {
67115
it('allows embedding and Office.js without relaxing object-src or base-uri', () => {
68116
const policy = getChatEmbedCSPPolicy()

‎apps/sim/lib/core/security/csp.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CONSENT_BACKEND_URL } from '../../consent/constants'
2-
import { env, getEnv } from '../config/env'
2+
import { env, envBoolean, getEnv } from '../config/env'
33
import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags'
44

55
/**
@@ -44,6 +44,34 @@ function getHostnameFromUrl(url: string | undefined): string[] {
4444
}
4545
}
4646

47+
const IPV4_HOSTNAME = /^\d{1,3}(\.\d{1,3}){3}$/
48+
49+
/**
50+
* Origins the browser PUTs presigned uploads to for a custom `S3_ENDPOINT`. The
51+
* endpoint origin itself is always allowed: the S3 SDK falls back to path-style
52+
* for IP hosts and bucket names that aren't DNS-safe even without
53+
* `S3_FORCE_PATH_STYLE`. Virtual-hosted addressing also needs the bucket
54+
* subdomains, which a `*.` source matches (never the bare host). Ports are kept
55+
* because a host-source without one only matches the scheme's default port.
56+
*/
57+
function getS3EndpointSources(
58+
endpoint: string | undefined,
59+
forcePathStyle: string | undefined
60+
): string[] {
61+
if (!endpoint) return []
62+
let url: URL
63+
try {
64+
url = new URL(endpoint)
65+
} catch {
66+
return []
67+
}
68+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return []
69+
const origin = `${url.protocol}//${url.host}`
70+
const isIpHost = IPV4_HOSTNAME.test(url.hostname) || url.hostname.startsWith('[')
71+
if (envBoolean(forcePathStyle) || isIpHost) return [origin]
72+
return [origin, `${url.protocol}//*.${url.host}`]
73+
}
74+
4775
export interface CSPDirectives {
4876
'default-src'?: string[]
4977
'script-src'?: string[]
@@ -199,6 +227,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
199227
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
200228
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
201229
...getHostnameFromUrl(env.NEXT_PUBLIC_TERMS_URL),
230+
...getS3EndpointSources(env.S3_ENDPOINT, env.S3_FORCE_PATH_STYLE),
202231
],
203232

204233
'frame-src': [...STATIC_FRAME_SRC],
@@ -247,6 +276,10 @@ export function generateRuntimeCSP(): string {
247276
const brandLogoDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL'))
248277
const privacyDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_PRIVACY_URL'))
249278
const termsDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_TERMS_URL'))
279+
const s3EndpointSources = getS3EndpointSources(
280+
getEnv('S3_ENDPOINT'),
281+
getEnv('S3_FORCE_PATH_STYLE')
282+
)
250283

251284
const runtimeDirectives: CSPDirectives = {
252285
...buildTimeCSPDirectives,
@@ -262,6 +295,7 @@ export function generateRuntimeCSP(): string {
262295
...brandLogoDomains,
263296
...privacyDomains,
264297
...termsDomains,
298+
...s3EndpointSources,
265299
],
266300
}
267301

‎apps/sim/lib/uploads/providers/s3/client.test.ts‎

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ import {
7474
deleteFromS3,
7575
deleteS3ObjectVersion,
7676
downloadFromS3,
77-
getS3PresignedUploadUrl,
7877
headS3Object,
7978
listS3MultipartParts,
8079
resetS3ClientForTesting,
@@ -225,35 +224,6 @@ describe('S3 Client', () => {
225224
})
226225

227226
describe('direct upload primitives', () => {
228-
it('signs metadata and a create-only condition without duplicate x-amz-meta headers', async () => {
229-
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/signed-put')
230-
231-
const result = await getS3PresignedUploadUrl({
232-
key: 'workspace/workspace-1/file.bin',
233-
contentType: 'application/octet-stream',
234-
fileSize: 3,
235-
metadata: { uploadId: 'upload-1', purpose: 'workspace_file' },
236-
customConfig: mockS3Config,
237-
expiresIn: 600,
238-
})
239-
240-
expect(mockPutObjectCommand).toHaveBeenCalledWith({
241-
Bucket: 'test-bucket',
242-
Key: 'workspace/workspace-1/file.bin',
243-
ContentType: 'application/octet-stream',
244-
ContentLength: 3,
245-
IfNoneMatch: '*',
246-
Metadata: { uploadId: 'upload-1', purpose: 'workspace_file' },
247-
})
248-
expect(result).toEqual({
249-
url: 'https://example.com/signed-put',
250-
headers: {
251-
'Content-Type': 'application/octet-stream',
252-
'If-None-Match': '*',
253-
},
254-
})
255-
})
256-
257227
it('lists every provider part across pagination', async () => {
258228
mockSend
259229
.mockResolvedValueOnce({

‎apps/sim/lib/uploads/providers/s3/client.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,13 @@ export async function getPresignedUrlWithConfig(
182182

183183
/**
184184
* Generates a create-only signed single-object PUT for a caller-selected final key.
185-
* The AWS presigner hoists `x-amz-meta-*` values into the signed query string,
186-
* so only ordinary transfer headers are returned. Repeating that metadata as
187-
* request headers makes S3 reject the otherwise-valid signature.
185+
*
186+
* By default the AWS presigner hoists `x-amz-meta-*` into the signed query string.
187+
* AWS S3 stores that as object metadata, but many S3-compatible stores (e.g.
188+
* OVHcloud) ignore it, so with a custom `S3_CONFIG.endpoint` the metadata is
189+
* signed as headers instead and returned for the uploader to send verbatim. AWS
190+
* keeps the query-string form so existing bucket CORS rules stay valid. A value
191+
* must never be both hoisted and sent as a header: S3 rejects the unsigned copy.
188192
*/
189193
export async function getS3PresignedUploadUrl(params: {
190194
key: string
@@ -203,12 +207,21 @@ export async function getS3PresignedUploadUrl(params: {
203207
IfNoneMatch: '*',
204208
Metadata: metadata,
205209
})
206-
const url = await getSignedUrl(getS3Client(), command, { expiresIn: params.expiresIn })
210+
const metadataHeaders: Record<string, string> = S3_CONFIG.endpoint
211+
? Object.fromEntries(
212+
Object.entries(metadata).map(([key, value]) => [`x-amz-meta-${key.toLowerCase()}`, value])
213+
)
214+
: {}
215+
const url = await getSignedUrl(getS3Client(), command, {
216+
expiresIn: params.expiresIn,
217+
unhoistableHeaders: new Set(Object.keys(metadataHeaders)),
218+
})
207219
return {
208220
url,
209221
headers: {
210222
'Content-Type': params.contentType,
211223
'If-None-Match': '*',
224+
...metadataHeaders,
212225
},
213226
}
214227
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Runs the real AWS SigV4 presigner (signing is local, no network) to pin where
3+
* direct-upload metadata travels. A header the uploader must send but that is
4+
* missing from `headers`, or a metadata key both signed as a header and hoisted
5+
* into the query, makes the provider reject or strip the upload.
6+
*/
7+
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
8+
import { setUploadsConfig, uploadsConfigMock } from '@sim/testing/mocks/uploads-config.mock'
9+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
vi.mock('@/lib/uploads/config', () => uploadsConfigMock)
12+
13+
import { getS3PresignedUploadUrl, resetS3ClientForTesting } from '@/lib/uploads/providers/s3/client'
14+
15+
setEnv({ AWS_ACCESS_KEY_ID: 'test-access-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key' })
16+
afterAll(resetEnvMock)
17+
18+
/** Headers the browser sets itself and the uploader never supplies. */
19+
const TRANSPORT_HEADERS = new Set(['host', 'content-length'])
20+
21+
function configure(endpoint: string | undefined, forcePathStyle = false) {
22+
setUploadsConfig({
23+
S3_CONFIG: { bucket: 'sim-files', region: 'de', endpoint, forcePathStyle },
24+
})
25+
resetS3ClientForTesting()
26+
}
27+
28+
async function presign() {
29+
const transfer = await getS3PresignedUploadUrl({
30+
key: 'kb/upload-1/report.pdf',
31+
contentType: 'application/pdf',
32+
fileSize: 3,
33+
metadata: { uploadId: 'upload-1', originalName: 'Q3 report.pdf' },
34+
customConfig: { bucket: 'sim-files', region: 'de' },
35+
expiresIn: 600,
36+
})
37+
const url = new URL(transfer.url)
38+
const signedHeaders = (url.searchParams.get('X-Amz-SignedHeaders') ?? '').split(';')
39+
const queryMetadata = [...url.searchParams.keys()].filter((k) =>
40+
k.toLowerCase().startsWith('x-amz-meta-')
41+
)
42+
const suppliedHeaders = new Set(Object.keys(transfer.headers).map((k) => k.toLowerCase()))
43+
return { transfer, signedHeaders, queryMetadata, suppliedHeaders }
44+
}
45+
46+
describe('getS3PresignedUploadUrl', () => {
47+
beforeEach(() => configure(undefined))
48+
49+
it('signs metadata as uploader-sent headers for a custom S3-compatible endpoint', async () => {
50+
configure('https://s3.de.io.cloud.ovh.net')
51+
const { transfer, signedHeaders, queryMetadata } = await presign()
52+
53+
expect(queryMetadata).toEqual([])
54+
expect(signedHeaders).toEqual(
55+
expect.arrayContaining(['x-amz-meta-uploadid', 'x-amz-meta-originalname'])
56+
)
57+
expect(transfer.headers).toMatchObject({
58+
'x-amz-meta-uploadid': 'upload-1',
59+
'x-amz-meta-originalname': 'Q3 report.pdf',
60+
})
61+
})
62+
63+
it('keeps AWS metadata in the signed query so existing bucket CORS rules still apply', async () => {
64+
const { signedHeaders, queryMetadata } = await presign()
65+
66+
expect(queryMetadata.sort()).toEqual(['x-amz-meta-originalname', 'x-amz-meta-uploadid'])
67+
expect(signedHeaders.some((h) => h.startsWith('x-amz-meta-'))).toBe(false)
68+
})
69+
70+
it.each([
71+
['AWS', undefined],
72+
['custom endpoint', 'https://s3.de.io.cloud.ovh.net'],
73+
])('supplies every signed header the uploader controls (%s)', async (_, endpoint) => {
74+
configure(endpoint)
75+
const { signedHeaders, suppliedHeaders, queryMetadata } = await presign()
76+
77+
for (const header of signedHeaders) {
78+
if (!TRANSPORT_HEADERS.has(header)) expect(suppliedHeaders).toContain(header)
79+
}
80+
for (const key of queryMetadata) expect(suppliedHeaders).not.toContain(key.toLowerCase())
81+
})
82+
})

0 commit comments

Comments
 (0)