Skip to content

Commit fc844c0

Browse files
committed
fix(security): harden saved-credential, proxy egress, and response-cap paths from the release audit
- Refuse session callers resolving saved service-account secrets on POST /api/auth/oauth/token - Refuse a proxied hostname when any resolved record is blocked (the proxy re-resolves) - Apply the response cap to every body, not only decoded ones (undici's http proxy wrapper ignores maxResponseSize) - Bind workload-identity subjects to their owner at mint time - Bound Databricks token/identity JSON reads; refuse single-slash https:/ in typed URLs; case-insensitive Dynatrace /api/vN strip - Twilio recordings: stay on the Twilio API origin and bound fetch concurrency - Firecrawl: count crawl maxDepth below the start URL; stop sending unused search sources - sim-setup reset refuses when Docker is installed but unreachable - Remove the dead requireDeployment branch from workflow access middleware; reuse requireMutableWorkflow; move a misplaced TSDoc - Docs: CONTRIBUTING setup via sim-setup, sim-setup README, Helm read-only-root mounts, proxy and NO_PROXY behavior, GitLab CSV body limit
1 parent 81c10c9 commit fc844c0

39 files changed

Lines changed: 519 additions & 193 deletions

File tree

‎.github/CONTRIBUTING.md‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,22 @@ 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 NPM Package (Simplest)
143+
### Option 1: Using the setup wizard (Simplest)
144144

145-
The easiest way to run Sim locally is using our NPM package:
145+
The easiest way to run Sim locally is the `sim-setup` wizard:
146146

147147
```bash
148-
npx simstudio
148+
npx sim-setup
149149
```
150150

151-
After running this command, open [http://localhost:3000/](http://localhost:3000/) in your browser.
152-
153-
#### Options
154-
155-
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
156-
- `--no-pull`: Skip pulling the latest Docker images
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.
157152

158153
#### Requirements
159154

160-
- Docker must be installed and running on your machine
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.
161159

162160
### Option 2: Using Docker Compose
163161

‎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 URL path depth \(e.g., 1 allows /docs and /docs/index.html\). Independent of link discovery depth |
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 |
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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ This depends on the runtime. The published images run Bun. If you build the stan
278278
| Everything through the SSRF guard — SSO OIDC discovery, the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers, and model endpoints typed into a block | Yes — see [Proxying guarded requests](#proxying-guarded-requests) |
279279
| SSO sign-in — the token, JWKS, and userinfo calls made while a user signs in | Yes — they use the default `fetch` |
280280
| SSO domain verification (TXT lookup) | No — DNS, sent to `1.1.1.1` and `8.8.8.8` unless `DOMAIN_VERIFICATION_NAMESERVERS` names your resolvers |
281-
| Amazon Bedrock, object storage on S3, and every AWS integration (DynamoDB, SQS, Lambda, Textract, and the rest) | Yes — each AWS client gets a proxy-aware agent while a proxy is set. Bedrock then uses HTTP/1.1 instead of HTTP/2 |
281+
| Amazon Bedrock, object storage on S3, and every AWS integration (DynamoDB, SQS, Lambda, Textract, and the rest) | Yes — each AWS service client gets a proxy-aware agent while a proxy is set, and STS calls made for its credentials inherit it. Bedrock then uses HTTP/1.1 instead of HTTP/2. IAM Identity Center (SSO) credential lookups do not use the proxy |
282282
| Email via SMTP, and the SMTP block | No — SMTP is not HTTP. Use a mail relay the app can reach directly, or Resend or SES |
283283
| Email via Amazon SES | Yes — the AWS SDK client above |
284284
| OTLP export from the server SDK | Yes — the exporters get the same proxy-aware agent |
@@ -289,7 +289,7 @@ The practical consequence: a mandatory-egress-proxy environment can route LLM tr
289289
290290
### Proxying guarded requests
291291
292-
A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks the address. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects.
292+
A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks any of the addresses it resolves to, since the proxy may connect to any of them. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects.
293293
294294
- **DNS must still resolve inside the container.** A destination that the app cannot resolve is refused before the proxy is asked.
295295
- **The proxy resolves the name again.** The app cannot pin the address across a proxy, so the proxy's own egress policy is the last check.
@@ -301,6 +301,8 @@ 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+
304306
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:
305307
306308
```yaml

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23929,7 +23929,7 @@
2392923929
"type": "string",
2393023930
"minLength": 1,
2393123931
"maxLength": 4194304,
23932-
"description": "UTF-8 CSV contents, up to 4 MiB."
23932+
"description": "UTF-8 CSV contents, up to 4 MiB. Both files travel in one request body limited to 10 MiB after JSON encoding."
2393323933
}
2393423934
},
2393523935
"required": ["filename", "content"],
@@ -23949,7 +23949,7 @@
2394923949
"type": "string",
2395023950
"minLength": 1,
2395123951
"maxLength": 4194304,
23952-
"description": "UTF-8 CSV contents, up to 4 MiB."
23952+
"description": "UTF-8 CSV contents, up to 4 MiB. Both files travel in one request body limited to 10 MiB after JSON encoding."
2395323953
}
2395423954
},
2395523955
"required": ["filename", "content"],

‎apps/sim/app/api/auth/oauth/token/route.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,111 @@ describe('OAuth Token API Routes', () => {
290290
})
291291
})
292292

293+
describe('saved service-account credentials', () => {
294+
const authorized = {
295+
ok: true,
296+
requesterUserId: 'test-user-id',
297+
credentialOwnerUserId: 'test-user-id',
298+
workspaceId: 'workspace-id',
299+
}
300+
301+
beforeEach(() => {
302+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
303+
accountId: '',
304+
credentialId: 'saved-credential-id',
305+
credentialType: 'service_account',
306+
providerId: 'vanta-service-account',
307+
workspaceId: 'workspace-id',
308+
usedCredentialTable: true,
309+
})
310+
authOAuthUtilsMockFns.mockResolveServiceAccountToken.mockResolvedValue({
311+
accessToken: 'saved-secret',
312+
})
313+
mockGetToolMetadata.mockReturnValue({
314+
id: 'vanta_list_tests',
315+
params: {},
316+
oauth: { required: true, provider: 'vanta', credentialKind: 'service-account' },
317+
})
318+
})
319+
320+
it('refuses to release the secret to a session caller', async () => {
321+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
322+
success: true,
323+
authType: 'session',
324+
userId: 'test-user-id',
325+
})
326+
mockAuthorizeCredentialUse.mockResolvedValueOnce(authorized)
327+
328+
const response = await POST(
329+
createMockRequest('POST', {
330+
credentialId: 'saved-credential-id',
331+
toolId: 'vanta_list_tests',
332+
})
333+
)
334+
335+
expect(response.status).toBe(403)
336+
const body = await response.json()
337+
expect(body).toMatchObject({ code: 'SERVICE_ACCOUNT_SESSION_FORBIDDEN' })
338+
expect(JSON.stringify(body)).not.toContain('saved-secret')
339+
expect(authOAuthUtilsMockFns.mockResolveServiceAccountToken).not.toHaveBeenCalled()
340+
})
341+
342+
it('resolves the secret for an internal-JWT execution caller', async () => {
343+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
344+
success: true,
345+
authType: 'internal_jwt',
346+
userId: 'test-user-id',
347+
})
348+
mockAuthorizeCredentialUse.mockResolvedValueOnce(authorized)
349+
350+
const response = await POST(
351+
createMockRequest('POST', {
352+
credentialId: 'saved-credential-id',
353+
toolId: 'vanta_list_tests',
354+
workflowId: 'workflow-id',
355+
})
356+
)
357+
358+
expect(response.status).toBe(200)
359+
await expect(response.json()).resolves.toMatchObject({
360+
accessToken: 'saved-secret',
361+
credentialType: 'service_account',
362+
})
363+
})
364+
})
365+
366+
it('still returns an OAuth token to a session caller', async () => {
367+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
368+
success: true,
369+
authType: 'session',
370+
userId: 'test-user-id',
371+
})
372+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
373+
ok: true,
374+
requesterUserId: 'test-user-id',
375+
credentialOwnerUserId: 'test-user-id',
376+
})
377+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
378+
id: 'credential-id',
379+
accessToken: 'test-token',
380+
refreshToken: 'refresh-token',
381+
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
382+
providerId: 'google',
383+
})
384+
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
385+
accessToken: 'session-oauth-token',
386+
refreshed: false,
387+
})
388+
389+
const response = await POST(createMockRequest('POST', { credentialId: 'credential-id' }))
390+
391+
expect(response.status).toBe(200)
392+
await expect(response.json()).resolves.toMatchObject({
393+
accessToken: 'session-oauth-token',
394+
credentialType: 'oauth',
395+
})
396+
})
397+
293398
describe('credentialAccountUserId + providerId path', () => {
294399
it('should reject internal JWT authentication', async () => {
295400
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({

‎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, false)
163+
const access = await validateWorkflowAccess(request, requestedWorkflowId)
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, false)
26+
const access = await validateWorkflowAccess(request, workflowId)
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, false)
17+
const access = await validateWorkflowAccess(request, workflowId)
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: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
/**
2-
* Tests for workflow access middleware — focused on the workspace-scoped
3-
* API key boundary check in the `requireDeployment=false` branch.
4-
*/
5-
61
import {
72
hybridAuthMockFns,
83
workflowAuthzMockFns,
@@ -13,10 +8,6 @@ import { NextRequest } from 'next/server'
138
import { beforeEach, describe, expect, it, vi } from 'vitest'
149

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

2112
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
2213
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
@@ -25,12 +16,11 @@ function makeRequest() {
2516
return new NextRequest(new URL('https://example.com/api/workflows/wf-1'))
2617
}
2718

28-
describe('validateWorkflowAccess (requireDeployment=false)', () => {
19+
describe('validateWorkflowAccess', () => {
2920
beforeEach(() => {
3021
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
3122
id: 'wf-1',
3223
workspaceId: 'ws-A',
33-
isDeployed: true,
3424
})
3525
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
3626
allowed: true,
@@ -48,7 +38,7 @@ describe('validateWorkflowAccess (requireDeployment=false)', () => {
4838
workspaceId: 'ws-B',
4939
})
5040

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

5343
expect(result.error).toEqual({
5444
message: WORKSPACE_KEY_SCOPE_DENIED,
@@ -70,7 +60,7 @@ describe('validateWorkflowAccess (requireDeployment=false)', () => {
7060
message: 'Access denied',
7161
})
7262

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

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

0 commit comments

Comments
 (0)