Skip to content

Commit 4efe999

Browse files
authored
v0.6.80: security hardening, nextjs minor version bump, cloudwatch tools, seo fixes
2 parents 6a5eebc + 62636c7 commit 4efe999

89 files changed

Lines changed: 2016 additions & 745 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"fumadocs-openapi": "10.8.1",
2727
"fumadocs-ui": "16.8.5",
2828
"lucide-react": "^0.511.0",
29-
"next": "16.2.4",
29+
"next": "16.2.6",
3030
"next-themes": "^0.4.6",
3131
"postgres": "^3.4.5",
3232
"react": "19.2.4",

apps/sim/app/(landing)/seo.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,21 @@ const SEO_SCAN_DIRS = [
2626

2727
const SEO_SCAN_INDIVIDUAL_FILES = [
2828
path.resolve(APP_DIR, 'page.tsx'),
29+
path.resolve(APP_DIR, 'robots.ts'),
30+
path.resolve(APP_DIR, 'sitemap.ts'),
2931
path.resolve(SIM_ROOT, 'ee', 'whitelabeling', 'metadata.ts'),
3032
]
3133

34+
/**
35+
* Files whose entire URL output is SEO-facing (robots.txt, sitemap.xml).
36+
* Unlike metadata exports, these don't use `metadataBase`, so the existing
37+
* `getBaseUrl()`-in-metadata check would miss a regression here.
38+
*/
39+
const SEO_DEFAULT_EXPORT_FILES = [
40+
path.resolve(APP_DIR, 'robots.ts'),
41+
path.resolve(APP_DIR, 'sitemap.ts'),
42+
]
43+
3244
function collectFiles(dir: string, exts: string[]): string[] {
3345
const results: string[] = []
3446
if (!fs.existsSync(dir)) return results
@@ -97,6 +109,21 @@ describe('SEO canonical URLs', () => {
97109
).toHaveLength(0)
98110
})
99111

112+
it('robots.ts and sitemap.ts do not import getBaseUrl', () => {
113+
const violations: string[] = []
114+
for (const file of SEO_DEFAULT_EXPORT_FILES) {
115+
if (!fs.existsSync(file)) continue
116+
const content = fs.readFileSync(file, 'utf-8')
117+
if (content.includes('getBaseUrl')) {
118+
violations.push(path.relative(SIM_ROOT, file))
119+
}
120+
}
121+
expect(
122+
violations,
123+
`robots.ts/sitemap.ts must use SITE_URL, not getBaseUrl():\n${violations.join('\n')}`
124+
).toHaveLength(0)
125+
})
126+
100127
it('public pages do not use getBaseUrl() for SEO metadata', () => {
101128
const files = getAllSeoFiles(['.ts', '.tsx'])
102129
const violations: string[] = []

apps/sim/app/api/files/authorization.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import { isUuid } from '@/executor/constants'
1414

1515
const logger = createLogger('FileAuthorization')
1616

17+
/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
18+
export class FileAccessDeniedError extends Error {
19+
constructor() {
20+
super('File not found')
21+
this.name = 'FileAccessDeniedError'
22+
}
23+
}
24+
1725
interface AuthorizationResult {
1826
granted: boolean
1927
reason: string
@@ -598,18 +606,14 @@ async function authorizeFileAccess(
598606
*/
599607
export async function assertToolFileAccess(
600608
key: unknown,
601-
userId: string | undefined,
609+
userId: string,
602610
requestId: string,
603611
routeLogger: ReturnType<typeof createLogger>
604612
): Promise<NextResponse | null> {
605613
if (typeof key !== 'string' || key.length === 0) {
606614
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
607615
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
608616
}
609-
if (!userId) {
610-
routeLogger.warn(`[${requestId}] File access check requires userId but none available`)
611-
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
612-
}
613617
const hasAccess = await verifyFileAccess(key, userId)
614618
if (!hasAccess) {
615619
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })

apps/sim/app/api/files/multipart/route.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({
5353

5454
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
5555

56+
const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
57+
mockCheckStorageQuota: vi.fn(),
58+
mockInitiateS3MultipartUpload: vi.fn(),
59+
}))
60+
61+
vi.mock('@/lib/billing/storage', () => ({
62+
checkStorageQuota: mockCheckStorageQuota,
63+
}))
64+
5665
import { POST } from '@/app/api/files/multipart/route'
5766

5867
const tokenPayload = {
@@ -200,3 +209,69 @@ describe('POST /api/files/multipart action=complete', () => {
200209
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
201210
})
202211
})
212+
213+
describe('POST /api/files/multipart action=initiate quota enforcement', () => {
214+
const makeInitiateRequest = (body: unknown) =>
215+
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
216+
method: 'POST',
217+
headers: { 'Content-Type': 'application/json' },
218+
body: JSON.stringify(body),
219+
})
220+
221+
beforeEach(() => {
222+
vi.clearAllMocks()
223+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
224+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
225+
mockIsUsingCloudStorage.mockReturnValue(true)
226+
mockGetStorageProvider.mockReturnValue('s3')
227+
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
228+
mockSignUploadToken.mockReturnValue('signed-token')
229+
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
230+
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
231+
})
232+
233+
it('blocks upload when fileSize: 0 exceeds quota', async () => {
234+
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
235+
236+
const res = await makeInitiateRequest({
237+
fileName: 'file.bin',
238+
contentType: 'application/octet-stream',
239+
fileSize: 0,
240+
workspaceId: 'ws-1',
241+
context: 'knowledge-base',
242+
})
243+
244+
const response = await POST(res)
245+
expect(response.status).toBe(413)
246+
const body = await response.json()
247+
expect(body.error).toContain('Storage limit exceeded')
248+
})
249+
250+
it('does not check quota for quota-exempt contexts (og-images)', async () => {
251+
const res = await makeInitiateRequest({
252+
fileName: 'img.png',
253+
contentType: 'image/png',
254+
fileSize: 99999,
255+
workspaceId: 'ws-1',
256+
context: 'og-images',
257+
})
258+
259+
const response = await POST(res)
260+
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
261+
})
262+
263+
it('rejects logs context — not allowed via the multipart endpoint', async () => {
264+
const res = await makeInitiateRequest({
265+
fileName: 'exec.log',
266+
contentType: 'text/plain',
267+
fileSize: 1000,
268+
workspaceId: 'ws-1',
269+
context: 'logs',
270+
})
271+
272+
const response = await POST(res)
273+
expect(response.status).toBe(400)
274+
const body = await response.json()
275+
expect(body.error).toMatch(/invalid storage context/i)
276+
})
277+
})

apps/sim/app/api/files/multipart/route.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type UploadTokenPayload,
2323
verifyUploadToken,
2424
} from '@/lib/uploads/core/upload-token'
25-
import type { StorageConfig } from '@/lib/uploads/shared/types'
25+
import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
2626
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2727

2828
const logger = createLogger('MultipartUploadAPI')
@@ -36,7 +36,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
3636
'workspace',
3737
'profile-pictures',
3838
'og-images',
39-
'logs',
4039
'workspace-logos',
4140
])
4241

@@ -135,6 +134,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
135134

136135
const config = getStorageConfig(storageContext)
137136

137+
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) {
138+
const { checkStorageQuota } = await import('@/lib/billing/storage')
139+
const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
140+
if (!quotaCheck.allowed) {
141+
return NextResponse.json(
142+
{ error: quotaCheck.error || 'Storage limit exceeded' },
143+
{ status: 413 }
144+
)
145+
}
146+
}
147+
138148
let customKey: string | undefined
139149
if (context === 'workspace' || context === 'mothership') {
140150
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
@@ -149,15 +159,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
149159
'@/lib/uploads/contexts/workspace/workspace-file-manager'
150160
)
151161
customKey = generateWorkspaceFileKey(workspaceId, fileName)
152-
153-
const { checkStorageQuota } = await import('@/lib/billing/storage')
154-
const quotaCheck = await checkStorageQuota(userId, fileSize)
155-
if (!quotaCheck.allowed) {
156-
return NextResponse.json(
157-
{ error: quotaCheck.error || 'Storage limit exceeded' },
158-
{ status: 413 }
159-
)
160-
}
161162
} else if (context === 'execution') {
162163
const workflowId = (data as { workflowId?: unknown }).workflowId
163164
const executionId = (data as { executionId?: unknown }).executionId

0 commit comments

Comments
 (0)