Skip to content

Commit dba6053

Browse files
committed
fix(security): re-scope to owned release-audit items on current staging
Drops the response cap and crawl depth (landed in #9860), CONTRIBUTING (#9860), Firecrawl search sources (on hold), Databricks JSON caps (#9859), and the workflow middleware and route-policies cleanups (#9858). Drops the NO_PROXY entry-syntax docs line (owned by a separate NO_PROXY change). Generated files regenerated on current staging.
2 parents fc844c0 + 03bf275 commit dba6053

22 files changed

Lines changed: 300 additions & 123 deletions

File tree

‎.github/CONTRIBUTING.md‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,20 @@ Using clear and consistent commit messages makes it easier for everyone to under
140140

141141
To set up your local development environment:
142142

143-
### Option 1: Using the setup wizard (Simplest)
143+
### Option 1: Using the Setup Wizard (Simplest)
144144

145-
The easiest way to run Sim locally is the `sim-setup` wizard:
145+
The easiest way to run Sim locally is the interactive setup wizard:
146146

147147
```bash
148148
npx sim-setup
149149
```
150150

151-
The wizard checks Docker, ports, memory, and disk space, generates the required secrets, writes `.env` and a Docker Compose file into a new `sim` directory, starts the containers, runs database migrations, and waits for the health checks to pass. When it finishes, open [http://localhost:3000/](http://localhost:3000/) in your browser.
151+
When it finishes, open [http://localhost:3000/](http://localhost:3000/) in your browser. Inside a cloned Sim repository, run `bun run sim-setup --mode dev` for the local development mode.
152152

153153
#### Requirements
154154

155-
- Node.js 20 or newer
156-
- Docker with the Compose plugin, installed and running
157-
158-
See the [`sim-setup` README](../packages/sim-setup/README.md) for options and management commands.
155+
- Node.js 20+
156+
- Docker must be installed and running on your machine
159157

160158
### Option 2: Using Docker Compose
161159

‎apps/docs/content/docs/integrations/firecrawl.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Crawl entire websites and extract structured content from all accessible pages
184184
| --------- | ---- | -------- | ----------- |
185185
| `url` | string | Yes | The website URL to crawl \(e.g., "https://example.com" or "https://docs.example.com/guide") |
186186
| `limit` | number | No | Maximum number of pages to crawl \(e.g., 50, 100, 500\). Default: 100 |
187-
| `maxDepth` | number | No | Maximum path depth below the starting URL \(e.g., 1 from /docs allows /docs/guide and /docs/guide/index.html\). Independent of link discovery depth |
187+
| `maxDepth` | number | No | Maximum URL path depth below the starting URL \(e.g., from /docs, 1 allows /docs/guide\). Independent of link discovery depth |
188188
| `formats` | json | No | Output formats for scraped content \(e.g., \["markdown"\], \["markdown", "html"\], \["markdown", "links"\]\) |
189189
| `prompt` | string | No | Natural-language crawl guidance supplied by existing configurations |
190190
| `excludePaths` | json | No | URL paths to exclude from crawling \(e.g., \["/blog/*", "/admin/*", "/*.pdf"\]\) |

‎apps/docs/content/docs/platform/self-hosting/networking.mdx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,6 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac
301301
302302
Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference. AWS clients and OTLP export follow the same rule, so also add an internal S3-compatible store such as MinIO, AWS VPC endpoints, and an in-cluster OpenTelemetry collector.
303303
304-
Each entry is a hostname, a domain suffix (`.svc` or `*.svc`), or an IP address, optionally followed by `:port`. Bracket IPv6 addresses (`[::1]`). CIDR ranges are not supported. `*` disables the proxy only when it is the whole value.
305-
306304
Set it in the application environment, not your shell — under `app.env` on Helm, or the service's `environment:` on Compose. On Helm the suffixes alone are not enough: the chart wires `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL` to bare Service names, which no domain suffix matches. Add those names too — `helm template` prints the rendered ones, and the prefix is the release name unless it already contains `sim`, in which case it is the release name alone:
307305
308306
```yaml

‎apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const GET = withRouteHandler(
160160
}
161161
) => {
162162
const { workflowId: requestedWorkflowId } = await context.params
163-
const access = await validateWorkflowAccess(request, requestedWorkflowId)
163+
const access = await validateWorkflowAccess(request, requestedWorkflowId, false)
164164
if (access.error) {
165165
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
166166
}

‎apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const GET = withRouteHandler(
2323
const { id: workflowId, executionId } = parsed.data.params
2424
const { includeOutput, selectedOutputs } = parsed.data.query
2525

26-
const access = await validateWorkflowAccess(request, workflowId)
26+
const access = await validateWorkflowAccess(request, workflowId, false)
2727
if (access.error) {
2828
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
2929
}

‎apps/sim/app/api/workflows/[id]/paused/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const GET = withRouteHandler(
1414
if (!parsed.success) return parsed.response
1515
const { id: workflowId } = parsed.data.params
1616

17-
const access = await validateWorkflowAccess(request, workflowId)
17+
const access = await validateWorkflowAccess(request, workflowId, false)
1818
if (access.error) {
1919
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
2020
}

‎apps/sim/app/api/workflows/middleware.test.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
/**
2+
* Tests for workflow access middleware — focused on the workspace-scoped
3+
* API key boundary check in the `requireDeployment=false` branch.
4+
*/
5+
16
import {
27
hybridAuthMockFns,
38
workflowAuthzMockFns,
@@ -8,6 +13,10 @@ import { NextRequest } from 'next/server'
813
import { beforeEach, describe, expect, it, vi } from 'vitest'
914

1015
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
16+
vi.mock('@/lib/api-key/service', () => ({
17+
authenticateApiKeyFromHeader: vi.fn(),
18+
updateApiKeyLastUsed: vi.fn(),
19+
}))
1120

1221
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
1322
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
@@ -16,11 +25,12 @@ function makeRequest() {
1625
return new NextRequest(new URL('https://example.com/api/workflows/wf-1'))
1726
}
1827

19-
describe('validateWorkflowAccess', () => {
28+
describe('validateWorkflowAccess (requireDeployment=false)', () => {
2029
beforeEach(() => {
2130
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
2231
id: 'wf-1',
2332
workspaceId: 'ws-A',
33+
isDeployed: true,
2434
})
2535
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
2636
allowed: true,
@@ -38,7 +48,7 @@ describe('validateWorkflowAccess', () => {
3848
workspaceId: 'ws-B',
3949
})
4050

41-
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')
51+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
4252

4353
expect(result.error).toEqual({
4454
message: WORKSPACE_KEY_SCOPE_DENIED,
@@ -60,7 +70,7 @@ describe('validateWorkflowAccess', () => {
6070
message: 'Access denied',
6171
})
6272

63-
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')
73+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
6474

6575
expect(result.error).toEqual({ message: 'Access denied', status: 403 })
6676
})

‎apps/sim/app/api/workflows/middleware.ts‎

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger'
22
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
33
import type { NextRequest } from 'next/server'
44
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
5+
import {
6+
type ApiKeyAuthResult,
7+
authenticateApiKeyFromHeader,
8+
updateApiKeyLastUsed,
9+
} from '@/lib/api-key/service'
510
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
611
import { getWorkflowById } from '@/lib/workflows/utils'
712

@@ -15,7 +20,8 @@ export interface ValidationResult {
1520

1621
export async function validateWorkflowAccess(
1722
request: NextRequest,
18-
workflowId: string
23+
workflowId: string,
24+
requireDeployment = true
1925
): Promise<ValidationResult> {
2026
try {
2127
const workflow = await getWorkflowById(workflowId)
@@ -38,40 +44,95 @@ export async function validateWorkflowAccess(
3844
}
3945
}
4046

41-
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
42-
if (!auth.success || !auth.userId) {
43-
return {
44-
error: {
45-
message: auth.error || 'Unauthorized',
46-
status: 401,
47-
},
47+
if (!requireDeployment) {
48+
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
49+
if (!auth.success || !auth.userId) {
50+
return {
51+
error: {
52+
message: auth.error || 'Unauthorized',
53+
status: 401,
54+
},
55+
}
4856
}
49-
}
5057

51-
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
52-
return {
53-
error: {
54-
message: WORKSPACE_KEY_SCOPE_DENIED,
55-
status: 403,
56-
},
58+
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
59+
return {
60+
error: {
61+
message: WORKSPACE_KEY_SCOPE_DENIED,
62+
status: 403,
63+
},
64+
}
5765
}
58-
}
5966

60-
const authorization = await authorizeWorkflowByWorkspacePermission({
61-
workflowId,
62-
userId: auth.userId,
63-
action: 'read',
64-
})
65-
if (!authorization.allowed) {
66-
return {
67-
error: {
68-
message: authorization.message || 'Access denied',
69-
status: authorization.status,
70-
},
67+
const authorization = await authorizeWorkflowByWorkspacePermission({
68+
workflowId,
69+
userId: auth.userId,
70+
action: 'read',
71+
})
72+
if (!authorization.allowed) {
73+
return {
74+
error: {
75+
message: authorization.message || 'Access denied',
76+
status: authorization.status,
77+
},
78+
}
7179
}
80+
81+
return { workflow, auth }
7282
}
7383

74-
return { workflow, auth }
84+
if (requireDeployment) {
85+
if (!workflow.isDeployed) {
86+
return {
87+
error: {
88+
message: 'Workflow is not deployed',
89+
status: 403,
90+
},
91+
}
92+
}
93+
94+
let apiKeyHeader = null
95+
for (const [key, value] of request.headers.entries()) {
96+
if (key.toLowerCase() === 'x-api-key' && value) {
97+
apiKeyHeader = value
98+
break
99+
}
100+
}
101+
102+
if (!apiKeyHeader) {
103+
return {
104+
error: {
105+
message: 'Unauthorized: API key required',
106+
status: 401,
107+
},
108+
}
109+
}
110+
111+
let validResult: ApiKeyAuthResult | null = null
112+
113+
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
114+
workspaceId: workflow.workspaceId as string,
115+
keyTypes: ['workspace', 'personal'],
116+
})
117+
118+
if (workspaceResult.success) {
119+
validResult = workspaceResult
120+
}
121+
122+
if (!validResult) {
123+
return {
124+
error: {
125+
message: 'Unauthorized: Invalid API key',
126+
status: 401,
127+
},
128+
}
129+
}
130+
131+
if (validResult.keyId) {
132+
updateApiKeyLastUsed(validResult.keyId)
133+
}
134+
}
135+
return { workflow }
75136
} catch (error) {
76137
logger.error('Validation error:', { error })
77138
return {

‎apps/sim/bootstrap.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
/**
2-
* Container entrypoint. Hydrates `process.env` from the runtime secret before
3-
* loading the Next.js standalone server, so application modules that read env at
4-
* import time see the full configuration. See `@sim/runtime-secrets`.
2+
* Container entrypoint. Hydrates `process.env` from the runtime secret and drops Next's route
3+
* cache when an earlier build wrote it, before loading the Next.js standalone server, so
4+
* application modules that read env at import time see the full configuration. See
5+
* `@sim/runtime-secrets`.
56
*/
7+
import { fileURLToPath } from 'node:url'
68
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
9+
import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache'
710

811
await loadRuntimeSecrets()
12+
await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url)))
913
// `server.js` is the Next standalone build artifact, a sibling of this file in
1014
// the image; it does not exist at type-check time, so the specifier is held in a
1115
// variable to keep it out of static module resolution.

‎apps/sim/lib/core/network/environment-proxy.server.test.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,23 @@ describe('guarded requests under HTTPS_PROXY', () => {
207207
])
208208
})
209209

210-
it('caps an uncompressed plain-http body forwarded by the proxy', async () => {
211-
const pinned = createPinnedFetchWithDispatcher('1.1.1.1', {
210+
it('caps a forwarded plain-http response at maxResponseSize', async () => {
211+
const guarded = createSsrfGuardedFetchWithDispatcher({
212+
...options,
212213
profile: 'selfHostedService',
213214
maxResponseSize: 4,
214215
})
215216
try {
216-
const response = await pinned.fetch('http://origin.invalid/plain')
217-
await expect(response.text()).rejects.toThrow(/max size/i)
217+
const response = await guarded.fetch('http://origin.invalid/plain')
218+
await expect(response.text()).rejects.toMatchObject({
219+
code: 'UND_ERR_RES_EXCEEDED_MAX_SIZE',
220+
})
218221
} finally {
219-
await pinned.dispatcher.destroy()
222+
await guarded.dispatcher.destroy()
220223
}
221-
expect(forwarded.map((request) => request.url)).toEqual(['http://origin.invalid/plain'])
224+
expect(forwarded).toEqual([
225+
{ url: 'http://origin.invalid/plain', authorization: PROXY_AUTHORIZATION },
226+
])
222227
})
223228

224229
it('connects directly, still pinned, when NO_PROXY names the host', async () => {

0 commit comments

Comments
 (0)