Skip to content

Commit a12832d

Browse files
authored
fix(release): cap proxied plain-http responses, count Firecrawl depth from the start URL, and drop stale route cache on upgrade (#9860)
* fix(release): cap proxied plain-http responses, count Firecrawl depth from the start URL, and drop stale route cache on upgrade * fix(firecrawl): read a scheme-less start URL as https when counting depth
1 parent 81c10c9 commit a12832d

10 files changed

Lines changed: 156 additions & 23 deletions

File tree

‎.github/CONTRIBUTING.md‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,23 +140,19 @@ 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 interactive 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+
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.
157152

158153
#### Requirements
159154

155+
- Node.js 20+
160156
- Docker must be installed and running on your machine
161157

162158
### Option 2: Using Docker Compose

‎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 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/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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,25 @@ describe('guarded requests under HTTPS_PROXY', () => {
206206
])
207207
})
208208

209+
it('caps a forwarded plain-http response at maxResponseSize', async () => {
210+
const guarded = createSsrfGuardedFetchWithDispatcher({
211+
...options,
212+
profile: 'selfHostedService',
213+
maxResponseSize: 4,
214+
})
215+
try {
216+
const response = await guarded.fetch('http://origin.invalid/plain')
217+
await expect(response.text()).rejects.toMatchObject({
218+
code: 'UND_ERR_RES_EXCEEDED_MAX_SIZE',
219+
})
220+
} finally {
221+
await guarded.dispatcher.destroy()
222+
}
223+
expect(forwarded).toEqual([
224+
{ url: 'http://origin.invalid/plain', authorization: PROXY_AUTHORIZATION },
225+
])
226+
})
227+
209228
it('connects directly, still pinned, when NO_PROXY names the host', async () => {
210229
vi.stubEnv('NO_PROXY', 'localhost,.invalid')
211230
const response = await secureFetchWithPinnedIP(

‎apps/sim/lib/core/security/input-validation.server.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -893,8 +893,7 @@ async function undiciRequestAsResponse(
893893
return response
894894
}
895895

896-
// Decode Content-Encoding like `fetch` does (`request()` returns raw bytes). `maxResponseSize`
897-
// still caps the compressed wire bytes on `body`.
896+
// Decode Content-Encoding like `fetch` does (`request()` returns raw bytes).
898897
const contentEncoding = String(headers['content-encoding'] ?? '')
899898
.toLowerCase()
900899
.trim()
@@ -909,13 +908,16 @@ async function undiciRequestAsResponse(
909908
// AFTER it means a synchronous zlib error (e.g. a server mislabeling a non-gzip body as gzip)
910909
// is caught and rejects the reader instead of taking down the process.
911910
let webBody = nodeReadableToWebStream(decoder ?? body)
912-
if (decoder && maxResponseSize !== undefined && maxResponseSize >= 0) {
913-
let decodedBytes = 0
911+
// Counted here rather than left to the dispatcher: undici's ProxyAgent forwards plain-http
912+
// requests through a client that drops `maxResponseSize`, and a decoder's output is larger
913+
// than the wire bytes the dispatcher counts.
914+
if (maxResponseSize !== undefined && maxResponseSize >= 0) {
915+
let bodyBytes = 0
914916
webBody = webBody.pipeThrough(
915917
new TransformStream<Uint8Array, Uint8Array>({
916918
transform(chunk, controller) {
917-
decodedBytes += chunk.byteLength
918-
if (decodedBytes > maxResponseSize) throw new errors.ResponseExceededMaxSizeError()
919+
bodyBytes += chunk.byteLength
920+
if (bodyBytes > maxResponseSize) throw new errors.ResponseExceededMaxSizeError()
919921
controller.enqueue(chunk)
920922
},
921923
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache'
6+
7+
let nextDirectory: string
8+
let routeCache: string
9+
10+
beforeEach(async () => {
11+
nextDirectory = await mkdtemp(join(tmpdir(), 'route-cache-'))
12+
routeCache = join(nextDirectory, 'server', 'route-cache')
13+
await mkdir(join(routeCache, 'app-page'), { recursive: true })
14+
await writeFile(join(routeCache, 'app-page', 'contact.html'), '<html>old build</html>')
15+
await writeFile(join(nextDirectory, 'BUILD_ID'), 'build-2\n')
16+
})
17+
18+
afterEach(async () => {
19+
await rm(nextDirectory, { recursive: true, force: true })
20+
})
21+
22+
describe('clearRouteCacheFromOtherBuild', () => {
23+
it('clears pages rendered by an earlier build', async () => {
24+
await writeFile(join(routeCache, '.build-id'), 'build-1')
25+
await clearRouteCacheFromOtherBuild(nextDirectory)
26+
expect(await readdir(routeCache)).toEqual(['.build-id'])
27+
})
28+
29+
it('clears a cache that records no build', async () => {
30+
await clearRouteCacheFromOtherBuild(nextDirectory)
31+
expect(await readdir(routeCache)).toEqual(['.build-id'])
32+
})
33+
34+
it('keeps pages rendered by the current build across restarts', async () => {
35+
await clearRouteCacheFromOtherBuild(nextDirectory)
36+
await writeFile(join(routeCache, 'app-page.html'), '<html>current build</html>')
37+
await clearRouteCacheFromOtherBuild(nextDirectory)
38+
expect((await readdir(routeCache)).sort()).toEqual(['.build-id', 'app-page.html'])
39+
})
40+
41+
it('leaves the cache alone when the build id is unreadable', async () => {
42+
await rm(join(nextDirectory, 'BUILD_ID'))
43+
await clearRouteCacheFromOtherBuild(nextDirectory)
44+
expect(await readdir(routeCache)).toEqual(['app-page'])
45+
})
46+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { join } from 'node:path'
3+
4+
const BUILD_MARKER = '.build-id'
5+
6+
/**
7+
* Clears Next's route cache when it was written by a different build. Its keys carry no build
8+
* id, and the image declares the directory a volume, so a container recreated from a newer
9+
* image would otherwise serve pages the old build rendered, pointing at chunks it no longer
10+
* ships. Never throws: a cache that cannot be read or cleared is left to Next.
11+
*/
12+
export async function clearRouteCacheFromOtherBuild(nextDirectory: string): Promise<void> {
13+
const routeCache = join(nextDirectory, 'server', 'route-cache')
14+
try {
15+
const buildId = (await readFile(join(nextDirectory, 'BUILD_ID'), 'utf8')).trim()
16+
if (!buildId) return
17+
const cachedBuildId = await readFile(join(routeCache, BUILD_MARKER), 'utf8').catch(() => null)
18+
if (cachedBuildId?.trim() === buildId) return
19+
const entries = await readdir(routeCache)
20+
await Promise.all(
21+
entries.map((entry) => rm(join(routeCache, entry), { recursive: true, force: true }))
22+
)
23+
await writeFile(join(routeCache, BUILD_MARKER), buildId)
24+
} catch {}
25+
}

‎apps/sim/tools/firecrawl/crawl.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { crawlTool } from '@/tools/firecrawl/crawl'
44

55
function crawlBody(options: {
66
maxDepth: number
7+
url?: string
78
maxDiscoveryDepth?: number
89
includePaths?: string[]
910
excludePaths?: string[]
@@ -66,6 +67,31 @@ describe('Firecrawl legacy crawl depth', () => {
6667
expect(patterns.some((pattern) => pattern.test(path))).toBe(excluded)
6768
})
6869

70+
it.each<[string, string, boolean]>([
71+
['https://example.com/docs/guide', '/docs/guide', true],
72+
['https://example.com/docs/guide/', '/docs/guide/setup', true],
73+
['https://example.com/docs/guide/index.html', '/docs/guide/setup', true],
74+
['https://example.com/docs/guide', '/docs/guide/setup/advanced', false],
75+
['docs.example.com/guide', '/guide/setup', true],
76+
])('counts depth from the start URL %s for %s', (url, path, accepted) => {
77+
const body = crawlBody({ maxDepth: 1, url })
78+
const patterns = (body.includePaths as string[]).map((pattern) => new RegExp(pattern))
79+
expect(patterns.some((pattern) => pattern.test(path))).toBe(accepted)
80+
})
81+
82+
it('counts depth from the start URL alongside existing path filters', () => {
83+
const body = crawlBody({
84+
maxDepth: 1,
85+
url: 'https://example.com/docs/guide',
86+
includePaths: ['^/docs'],
87+
})
88+
const patterns = (body.excludePaths as string[]).map((pattern) => new RegExp(pattern))
89+
const excludes = (path: string) => patterns.some((pattern) => pattern.test(path))
90+
expect(excludes('/docs/guide')).toBe(false)
91+
expect(excludes('/docs/guide/setup')).toBe(false)
92+
expect(excludes('/docs/guide/setup/advanced')).toBe(true)
93+
})
94+
6995
it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
7096
'rejects an invalid legacy depth: %s',
7197
(maxDepth) => {

‎apps/sim/tools/firecrawl/crawl.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ const logger = createLogger('FirecrawlCrawlTool')
1818
const POLL_INTERVAL_MS = 5000
1919
const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS
2020

21+
/**
22+
* Path segments of a URL as Firecrawl v1 counted them: index files add no depth. A URL without
23+
* a scheme is read as https, as Firecrawl reads it.
24+
*/
25+
function urlPathDepth(url: string): number {
26+
const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(url) ? url : `https://${url}`
27+
if (!URL.canParse(absolute)) return 0
28+
return new URL(absolute).pathname
29+
.split('/')
30+
.filter((segment) => segment !== '' && segment !== 'index.html' && segment !== 'index.php')
31+
.length
32+
}
33+
2134
export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse> = {
2235
id: 'firecrawl_crawl',
2336
name: 'Firecrawl Crawl',
@@ -42,7 +55,7 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
4255
required: false,
4356
visibility: 'user-or-llm',
4457
description:
45-
'Maximum URL path depth (e.g., 1 allows /docs and /docs/index.html). Independent of link discovery depth',
58+
'Maximum URL path depth below the starting URL (e.g., from /docs, 1 allows /docs/guide). Independent of link discovery depth',
4659
},
4760
formats: {
4861
type: 'json',
@@ -156,10 +169,12 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
156169
if (params.excludePaths) body.excludePaths = params.excludePaths
157170
if (params.includePaths) body.includePaths = params.includePaths
158171
if (params.maxDepth != null) {
159-
const maxDepth = Number(params.maxDepth)
160-
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0) {
172+
const requestedDepth = Number(params.maxDepth)
173+
if (!Number.isSafeInteger(requestedDepth) || requestedDepth < 0) {
161174
throw new Error('maxDepth must be a non-negative integer')
162175
}
176+
// v1 counted depth from the start URL, so the start page itself always passes the filter.
177+
const maxDepth = requestedDepth + urlPathDepth(params.url)
163178

164179
// v1 depth counted URL path segments; v2 discovery depth counts link hops.
165180
body.regexOnFullURL = false

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)