diff --git a/.changeset/brave-strings-harden.md b/.changeset/brave-strings-harden.md
new file mode 100644
index 00000000000..1b5be7c484d
--- /dev/null
+++ b/.changeset/brave-strings-harden.md
@@ -0,0 +1,8 @@
+---
+'@tanstack/router-core': patch
+'@tanstack/start-client-core': patch
+'@tanstack/start-plugin-core': patch
+'@tanstack/start-server-core': patch
+---
+
+harden string encoding/decoding: centralize URL-path primitives, fix URIError escape via route masks and matchRoute, add property-based and security tests across SSR scripts, frame protocol, server-fn payloads, virtual-module IDs and early hints
diff --git a/packages/router-core/docs/string-handling.md b/packages/router-core/docs/string-handling.md
new file mode 100644
index 00000000000..29b5b19eaf3
--- /dev/null
+++ b/packages/router-core/docs/string-handling.md
@@ -0,0 +1,97 @@
+# String handling in TanStack Router
+
+All URL-path string encoding/decoding primitives live in
+`router-core/src/string-encoding.ts`. ESLint (`no-restricted-globals` in that
+package's eslint config) bans direct use of `encodeURIComponent`,
+`decodeURIComponent`, `encodeURI`, `decodeURI`, `btoa` and `atob` anywhere else
+in router-core `src/` (with two documented exceptions).
+
+## Scope map: every encoding surface and where its guarantees are tested
+
+| # | Surface | Package / file | Tests |
+| --- | ----------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| 1 | URL path encode-on-write | `router-core/src/string-encoding.ts` (`encodePathParam`, `compileDecodeCharMap`) | `string-encoding.property.test.ts`, `path.test.ts` |
+| 1 | href encoding | `encodePathLikeUrl`, `buildDevStylesUrl` | `utils.test.ts`, property tests |
+| 2 | URL path decode-on-read (attacker input) | `decodePath`/`decodeSegment`; matcher `decodeParam` + `findMatch` choke point | `malformed-percent.test.ts`, `string-encoding.property.test.ts`, `utils.test.ts` |
+| 2 | prerender page validation (SSRF) | `start-plugin-core/src/prerender.ts` | `prerender-ssrf.test.ts` |
+| 3 | search params | `router-core/src/qss.ts`, `searchParams.ts` | `search-params.property.test.ts` |
+| 4 | SSR inline scripts (XSS) | `escapeHtml` + scroll-restoration script; seroval stream factories | `ssr-injection.test.ts`, `string-encoding.property.test.ts` |
+| 5 | binary ↔ string (SSR streams, RPC frames) | `RawStream.ts`, `frame-protocol.ts`, client `frame-decoder.ts` | `frame-protocol.test.ts`, `frame-decoder.test.ts` |
+| 6 | server-fn payload deserialization | `start-server-core/server-functions-handler.ts` | `server-functions-handler.test.ts` |
+| 7 | build-time base64url module IDs | `start-plugin-core/import-protection/virtualModules.ts` (+rsbuild twin) | `virtualModules-roundtrip.test.ts` |
+| 8 | persistence & headers | scroll-restoration JSON guards; early-hints Link headers | `scroll-restoration*.test.ts`, `early-hints-hardening.test.ts` |
+
+## Trust boundaries and guarantees
+
+| Function | Input trust | Guarantee |
+| ------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `decodePath` | attacker-controlled (URL) | total: never throws; strips control chars, `"`, `<`, `>`, backtick, braces; collapses protocol-relative prefixes |
+| `encodePathParam` | app data (params) | output safe for embedding in a single path segment |
+| `escapeHtml` | app data (may contain user content) | output cannot break out of `',
+ '"}; alert(1); {"',
+ "'+alert(1)+'",
+ "\\'; alert(1); \\'",
+ 'key\u2028with\u2029separators',
+ '${alert(1)}',
+ '
',
+ ]
+
+ test.each(maliciousKeys)(
+ 'script for key %j contains no context-breaking sequences',
+ (key) => {
+ const router = createScrollRestorationRouter(() => key)
+ const script = getScrollRestorationScriptForRouter(router)!
+ // nothing may close/reopen the surrounding {
+ const router = createScrollRestorationRouter(() => key)
+ const script = getScrollRestorationScriptForRouter(router)!
+
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({ [key]: { window: { scrollX: 11, scrollY: 22 } } }),
+ )
+ const scrollTo = vi.fn()
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ expect(() => new Function(script)()).not.toThrow()
+ // proves the escaped key round-tripped to exactly the original value:
+ // the inline script found our entry and scrolled
+ expect(scrollTo).toHaveBeenCalledWith(11, 22)
+ },
+ )
+
+ test('injected code inside a key is never executed', () => {
+ // if escaping were broken, this key would terminate the string literal
+ // and execute alert() during script evaluation
+ let executed = false
+ const router = createScrollRestorationRouter(() => '"}; alert(1); {"')
+ const script = getScrollRestorationScriptForRouter(router)!
+ vi.stubGlobal('alert', () => {
+ executed = true
+ })
+ new Function(script)()
+ expect(executed).toBe(false)
+ })
+})
+
+describe('seroval stream factory canary', () => {
+ // FACTORY_BINARY / FACTORY_TEXT are static minified JS shipped into inline
+ // scripts and evaluated client-side. They must never gain interpolation
+ // points - any future `${...}` or concatenation with non-literal data would
+ // become an eval-injection sink.
+ const src = readFileSync(
+ join(
+ dirname(fileURLToPath(import.meta.url)),
+ '../src/ssr/serializer/RawStream.ts',
+ ),
+ 'utf8',
+ )
+
+ function extractFactory(name: string): string {
+ const match = src.match(new RegExp(`${name}\\s*=\\s*([\\s\\S]*?)\\n`))
+ expect(match, `${name} not found in RawStream.ts`).not.toBeNull()
+ return match![1]!
+ }
+
+ test.each(['FACTORY_BINARY', 'FACTORY_TEXT'])(
+ '%s contains no interpolation points',
+ (name) => {
+ const literal = extractFactory(name)
+ expect(literal.startsWith('`')).toBe(true)
+ expect(literal).not.toContain('${')
+ expect(literal).not.toContain("' +")
+ expect(literal).not.toContain('+ `')
+ },
+ )
+})
diff --git a/packages/router-core/tests/string-encoding.bench.ts b/packages/router-core/tests/string-encoding.bench.ts
new file mode 100644
index 00000000000..0f9b58d5419
--- /dev/null
+++ b/packages/router-core/tests/string-encoding.bench.ts
@@ -0,0 +1,84 @@
+import { bench, describe } from 'vitest'
+import { decodePath } from '../src/string-encoding'
+import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree'
+import { interpolatePath } from '../src/path'
+
+/**
+ * Benchmarks for the hot string encoding/decoding paths.
+ *
+ * These run on every navigation, so regressions here are user-visible.
+ * Used to validate that hardening changes (e.g. safeDecodeURIComponent)
+ * do not degrade performance. Run with: pnpm vitest bench tests/string-encoding.bench.ts
+ */
+
+const tree = processRouteTree({
+ id: '__root__',
+ isRoot: true,
+ fullPath: '/',
+ path: '/',
+ children: [
+ { id: '/$', fullPath: '/$', path: '$' },
+ { id: '/posts', fullPath: '/posts', path: 'posts' },
+ { id: '/posts/$id', fullPath: '/posts/$id', path: 'posts/$id' },
+ { id: '/files/$', fullPath: '/files/$', path: 'files/$' },
+ {
+ id: '/users/$userId/settings',
+ fullPath: '/users/$userId/settings',
+ path: 'users/$userId/settings',
+ },
+ ],
+}).processedTree
+
+const plainPath = '/posts/123/settings'
+const unicodePath = '/café/日本語/🎉'
+const encodedPath = '/a%20b%2Fc%3Fd%23e'
+const params = {
+ id: 'hello world/with specials?',
+ _splat: 'docs/v1/getting started.md',
+}
+
+describe('decodePath', () => {
+ bench('plain ascii path (fast path)', () => {
+ decodePath(plainPath)
+ })
+
+ bench('unicode path', () => {
+ decodePath(unicodePath)
+ })
+
+ bench('heavily encoded path', () => {
+ decodePath(encodedPath)
+ })
+})
+
+describe('interpolatePath', () => {
+ bench('single param', () => {
+ interpolatePath({ path: '/posts/$id', params })
+ })
+
+ bench('multiple params + splat', () => {
+ interpolatePath({ path: '/users/$userId/files/$', params })
+ })
+})
+
+describe('findRouteMatch', () => {
+ bench('static match', () => {
+ findRouteMatch('/posts', tree)
+ })
+
+ bench('param match', () => {
+ findRouteMatch('/posts/123', tree)
+ })
+
+ bench('deep param match', () => {
+ findRouteMatch('/users/42/settings', tree)
+ })
+
+ bench('splat match', () => {
+ findRouteMatch('/files/docs/v1/readme.md', tree)
+ })
+
+ bench('encoded param match', () => {
+ findRouteMatch('/posts/hello%20world', tree)
+ })
+})
diff --git a/packages/router-core/tests/string-encoding.property.test.ts b/packages/router-core/tests/string-encoding.property.test.ts
new file mode 100644
index 00000000000..6ad59d60763
--- /dev/null
+++ b/packages/router-core/tests/string-encoding.property.test.ts
@@ -0,0 +1,250 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { decodePath, encodePathParam, escapeHtml } from '../src/string-encoding'
+import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree'
+import { interpolatePath } from '../src/path'
+import { decode as decodeQueryString } from '../src/qss'
+
+/**
+ * Property-based invariants for the string encoding boundary.
+ *
+ * Each property here encodes a guarantee documented in
+ * src/string-encoding.ts. If one of these fails, the guarantee is broken —
+ * fix the implementation or consciously change the documented contract.
+ */
+
+/** Strings with percent-encoding fragments and control characters injected. */
+const mangledStringArb = fc
+ .array(
+ fc.oneof(
+ fc.string(),
+ fc.constantFrom('%', '%25', '%zz', '%E4%BD', '%2F', '%00', '%0d', '%0a'),
+ fc.constantFrom('\r', '\n', '\x00', '\x7f', '<', '>', '"', '`', '{', '}'),
+ fc.constantFrom('//', '/evil.com'),
+ ),
+ { maxLength: 12 },
+ )
+ .map((parts) => parts.join(''))
+
+/** True if the string contains lone (unpaired) UTF-16 surrogates. */
+function hasLoneSurrogates(s: string): boolean {
+ const withoutPairs = s.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '')
+ return /[\uD800-\uDFFF]/.test(withoutPairs)
+}
+
+// Values containing lone surrogates are excluded: encodeURIComponent itself
+// does not round-trip those (it produces %ED%A0%80-style sequences that its
+// own decoder rejects), and the router treats them as malformed — no match.
+const roundTripValueArb = fc.string().filter((s) => !hasLoneSurrogates(s))
+
+/** Native decode, or null when the input is malformed (incl. lone surrogates). */
+function nativeDecode(s: string): string | null {
+ try {
+ return decodeURIComponent(s)
+ } catch {
+ return null
+ }
+}
+
+describe('encodePathParam properties', () => {
+ it('round-trips through decodeURIComponent for any value', () => {
+ fc.assert(
+ fc.property(roundTripValueArb, (value) => {
+ expect(decodeURIComponent(encodePathParam(value))).toBe(value)
+ }),
+ )
+ })
+
+ it('output never contains raw separators (?, #) or control characters', () => {
+ fc.assert(
+ fc.property(roundTripValueArb, (value) => {
+ const encoded = encodePathParam(value)
+ // eslint-disable-next-line no-control-regex
+ expect(encoded).not.toMatch(/[?#\r\n\0]/)
+ }),
+ )
+ })
+})
+
+describe('matcher integration properties', () => {
+ const tree = processRouteTree({
+ id: '__root__',
+ isRoot: true,
+ fullPath: '/',
+ path: '/',
+ children: [
+ { id: '/$id', fullPath: '/$id', path: '$id' },
+ { id: '/files/$', fullPath: '/files/$', path: 'files/$' },
+ { id: '/files/*', fullPath: '/files/*', path: 'files/*' },
+ ],
+ }).processedTree
+
+ it('matching is total: never throws for any path', () => {
+ fc.assert(
+ fc.property(mangledStringArb, (path) => {
+ const prefixed = `/${path.replace(/^\//, '')}`
+ expect(() => findRouteMatch(prefixed, tree)).not.toThrow()
+ expect(() => findRouteMatch(prefixed, tree, true)).not.toThrow()
+ }),
+ )
+ })
+
+ it('recovers a param value exactly after encode → decodePath → match', () => {
+ fc.assert(
+ fc.property(
+ roundTripValueArb.filter((v) => v !== '' && !v.includes('/')),
+ (value) => {
+ const { interpolatedPath } = interpolatePath({
+ path: '/$id',
+ params: { id: value },
+ })
+ const decoded = decodePath(interpolatedPath).path
+ const match = findRouteMatch(decoded, tree)
+ // whenever the value survives a native encode/decode cycle the router
+ // must recover it exactly; otherwise it must be treated as no-match
+ if (nativeDecode(encodeURIComponent(value)) === value) {
+ expect(match?.rawParams?.id).toBe(value)
+ } else {
+ expect(match).toBeNull()
+ }
+ },
+ ),
+ )
+ })
+
+ // KNOWN PRE-EXISTING QUIRK (documented, not fixed here): a splat value of
+ // `*` interpolates to `/files/*`, which collides with the legacy `*`
+ // wildcard syntax and therefore does not round-trip. Values containing `*`
+ // are excluded from the property below; if you fix the collision, remove
+ // the filter and this note.
+ it('splat params preserve slashes and round-trip', () => {
+ fc.assert(
+ fc.property(
+ roundTripValueArb.filter(
+ (v) =>
+ v !== '' &&
+ !v.startsWith('/') &&
+ !v.endsWith('/') &&
+ !v.includes('*'),
+ ),
+ (value) => {
+ const { interpolatedPath } = interpolatePath({
+ path: '/files/$',
+ params: { _splat: value },
+ })
+ const decoded = decodePath(interpolatedPath).path
+ const match = findRouteMatch(decoded, tree)
+ if (nativeDecode(encodeURIComponent(value)) === value) {
+ expect(match?.rawParams?._splat).toBe(value)
+ } else {
+ expect(match).toBeNull()
+ }
+ },
+ ),
+ )
+ })
+})
+
+describe('decodePath properties', () => {
+ it('is total: never throws for any input', () => {
+ fc.assert(
+ fc.property(mangledStringArb, (path) => {
+ expect(() => decodePath(path)).not.toThrow()
+ }),
+ )
+ })
+
+ it('never returns a protocol-relative path (open-redirect defense)', () => {
+ fc.assert(
+ fc.property(mangledStringArb, (path) => {
+ const { path: result } = decodePath(path)
+ expect(result.startsWith('//')).toBe(false)
+ }),
+ )
+ })
+
+ it('never introduces unsafe characters that were not already literal in the input', () => {
+ // Characters like `"`, `<`, `>`, control chars must stay percent-encoded
+ // after a decode round. Literal occurrences in the input pass through
+ // untouched (browsers percent-encode them anyway before they reach the
+ // router); decoding must never *create* new ones.
+ fc.assert(
+ fc.property(mangledStringArb, (path) => {
+ const { path: result } = decodePath(path)
+ const unsafeRe = /[\x00-\x1f\x7f"<>`{}]/g
+ const inputUnsafe = new Set(path.match(unsafeRe) ?? [])
+ for (const ch of result.match(unsafeRe) ?? []) {
+ expect(inputUnsafe.has(ch)).toBe(true)
+ }
+ }),
+ )
+ })
+})
+
+describe('escapeHtml properties', () => {
+ it('output cannot break out of a {
+ // escaping must never map two different JSON payloads to the same output,
+ // otherwise SSR'd data could be swapped by crafting input values
+ fc.assert(
+ fc.property(fc.string(), fc.string(), (a, b) => {
+ if (a === b) return
+ expect(escapeHtml(JSON.stringify(a))).not.toBe(
+ escapeHtml(JSON.stringify(b)),
+ )
+ }),
+ )
+ })
+})
+
+describe('search param decoding properties', () => {
+ it('decode always returns a null-prototype object (prototype-pollution defense)', () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.tuple(fc.string(), fc.string()), { maxLength: 8 }),
+ (entries) => {
+ const qs = entries
+ .map(
+ ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
+ )
+ .join('&')
+ const result = decodeQueryString(qs)
+ expect(Object.getPrototypeOf(result)).toBe(null)
+ },
+ ),
+ )
+ })
+
+ it('__proto__ keys never pollute Object.prototype', () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.string(), { maxLength: 5 }),
+ fc.string(),
+ (suffixValues, value) => {
+ const qs = ['__proto__=' + encodeURIComponent(value)]
+ .concat(suffixValues.map((v) => `${encodeURIComponent(v)}=1`))
+ .join('&')
+ const result = decodeQueryString(qs) as Record
+ const polluted = (Object.prototype as Record)
+ .polluted
+ expect(polluted).toBeUndefined()
+ expect(({} as Record).polluted).toBeUndefined()
+ // the key is still readable as own property data
+ expect(
+ Object.getOwnPropertyDescriptor(result, '__proto__'),
+ ).toBeDefined()
+ },
+ ),
+ )
+ })
+})
diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts
index 9e5c14c4138..fa681daa3df 100644
--- a/packages/router-core/tests/utils.test.ts
+++ b/packages/router-core/tests/utils.test.ts
@@ -1,12 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
decodePath,
- deepEqual,
encodePathLikeUrl,
escapeHtml,
- isPlainArray,
- replaceEqualDeep,
-} from '../src/utils'
+} from '../src/string-encoding'
+import { deepEqual, isPlainArray, replaceEqualDeep } from '../src/utils'
describe('replaceEqualDeep', () => {
it('should return the same object if the input objects are equal', () => {
diff --git a/packages/start-client-core/package.json b/packages/start-client-core/package.json
index a3dcea58bc8..a10009ae6f4 100644
--- a/packages/start-client-core/package.json
+++ b/packages/start-client-core/package.json
@@ -112,13 +112,14 @@
"seroval": "^1.6.2"
},
"devDependencies": {
- "vite": "*",
- "@types/node": ">=20",
+ "@arethetypeswrong/cli": "catalog:",
"@tanstack/vite-config": "catalog:",
- "vitest": "catalog:",
+ "@types/node": ">=20",
+ "fast-check": "^4.9.0",
"jsdom": "catalog:",
- "@arethetypeswrong/cli": "catalog:",
"publint": "catalog:",
- "rimraf": "catalog:"
+ "rimraf": "catalog:",
+ "vite": "*",
+ "vitest": "catalog:"
}
}
diff --git a/packages/start-client-core/src/tests/frame-decoder.test.ts b/packages/start-client-core/src/tests/frame-decoder.test.ts
new file mode 100644
index 00000000000..a1d8811ee9e
--- /dev/null
+++ b/packages/start-client-core/src/tests/frame-decoder.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+import { createFrameDecoder } from '../client-rpc/frame-decoder'
+import { FRAME_HEADER_SIZE, FrameType } from '../constants'
+import fc from 'fast-check'
+
+/**
+ * Client-side frame decoder: the binary framing protocol is parsed from raw
+ * HTTP response bytes (network = attacker-influenced). These tests pin the
+ * robustness contract: correct round-trips, chunk-boundary independence, and
+ * graceful failure on malformed/hostile input.
+ */
+
+function encodeFrame(
+ type: number,
+ streamId: number,
+ payload: Uint8Array,
+): Uint8Array {
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length)
+ frame[0] = type
+ frame[1] = (streamId >>> 24) & 0xff
+ frame[2] = (streamId >>> 16) & 0xff
+ frame[3] = (streamId >>> 8) & 0xff
+ frame[4] = streamId & 0xff
+ frame[5] = (payload.length >>> 24) & 0xff
+ frame[6] = (payload.length >>> 16) & 0xff
+ frame[7] = (payload.length >>> 8) & 0xff
+ frame[8] = payload.length & 0xff
+ frame.set(payload, FRAME_HEADER_SIZE)
+ return frame
+}
+
+function jsonFrame(json: string): Uint8Array {
+ return encodeFrame(FrameType.JSON, 0, new TextEncoder().encode(json))
+}
+
+function streamFromChunks(chunks: Array): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ for (const c of chunks) controller.enqueue(c)
+ controller.close()
+ },
+ })
+}
+
+async function readAllJson(decoder: ReturnType) {
+ const reader = decoder.chunks.getReader()
+ const lines: Array = []
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) break
+ lines.push(value!)
+ }
+ return lines
+}
+
+describe('frame decoder', () => {
+ it('round-trips JSON frames and binary chunks exactly', async () => {
+ const payload = new Uint8Array([0, 255, 1, 254, 128, 127])
+ const bytes = [
+ jsonFrame('{"step":1}'),
+ encodeFrame(FrameType.CHUNK, 7, payload),
+ encodeFrame(FrameType.END, 7, new Uint8Array(0)),
+ jsonFrame('{"step":2}'),
+ ]
+
+ const decoder = createFrameDecoder(streamFromChunks(bytes))
+ expect(await readAllJson(decoder)).toEqual(['{"step":1}', '{"step":2}'])
+
+ const stream = decoder.getStream(7)
+ const reader = stream.getReader()
+ expect((await reader.read()).value).toEqual(payload)
+ expect((await reader.read()).done).toBe(true)
+ })
+
+ it('produces identical output regardless of network chunk boundaries', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(fc.tuple(fc.string(), fc.uint8Array({ maxLength: 64 })), {
+ maxLength: 12,
+ }),
+ fc.integer({ min: 1, max: 5 }),
+ async (frames, splitSize) => {
+ let all = new Uint8Array(0)
+ for (const [json] of frames) {
+ all = Uint8Array.from([...all, ...jsonFrame(json)])
+ }
+ // feed bytes in fixed-size splits smaller than the frames
+ const chunks: Array = []
+ for (let i = 0; i < all.length; i += splitSize) {
+ chunks.push(all.subarray(i, i + splitSize))
+ }
+ const decoder = createFrameDecoder(streamFromChunks(chunks))
+ expect(await readAllJson(decoder)).toEqual(frames.map(([j]) => j))
+ },
+ ),
+ )
+ })
+
+ it('rejects unknown frame types instead of misinterpreting them', async () => {
+ const bytes = [encodeFrame(99, 1, new Uint8Array([1]))]
+ const decoder = createFrameDecoder(streamFromChunks(bytes))
+ await expect(readAllJson(decoder)).rejects.toThrow(/Unknown frame type/)
+ })
+
+ it('enforces streamId conventions', async () => {
+ const badJson = encodeFrame(FrameType.JSON, 5, new TextEncoder().encode('{}'))
+ const decoder = createFrameDecoder(streamFromChunks([badJson]))
+ await expect(readAllJson(decoder)).rejects.toThrow(/streamId/)
+
+ const badChunk = encodeFrame(FrameType.CHUNK, 0, new Uint8Array([1]))
+ const decoder2 = createFrameDecoder(streamFromChunks([badChunk]))
+ await expect(readAllJson(decoder2)).rejects.toThrow(/streamId/)
+ })
+
+ it('fails fast on absurd length headers without allocating them', async () => {
+ // length header = 0xFFFFFFFF (~4GB); must be rejected by the size cap
+ const hostile = new Uint8Array(FRAME_HEADER_SIZE)
+ hostile[0] = FrameType.CHUNK
+ hostile[1] = 0
+ hostile[2] = 0
+ hostile[3] = 0
+ hostile[4] = 1
+ hostile[5] = 0xff
+ hostile[6] = 0xff
+ hostile[7] = 0xff
+ hostile[8] = 0xff
+
+ const decoder = createFrameDecoder(streamFromChunks([hostile]))
+ await expect(readAllJson(decoder)).rejects.toThrow(/payload too large/i)
+ })
+
+ it('ignores a trailing truncated frame when the stream ends', async () => {
+ // a partial header/payload at end-of-stream must not hang or throw
+ const complete = jsonFrame('{"ok":true}')
+ const truncated = encodeFrame(FrameType.CHUNK, 3, new Uint8Array([9, 9])).subarray(0, 11)
+
+ const decoder = createFrameDecoder(
+ streamFromChunks([
+ Uint8Array.from([...complete, ...truncated]),
+ ]),
+ )
+ expect(await readAllJson(decoder)).toEqual(['{"ok":true}'])
+ })
+})
diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json
index 9a26dbb776d..8af39d63cbd 100644
--- a/packages/start-plugin-core/package.json
+++ b/packages/start-plugin-core/package.json
@@ -107,17 +107,18 @@
"zod": "^4.4.3"
},
"devDependencies": {
+ "@arethetypeswrong/cli": "catalog:",
"@rsbuild/core": "^2.1.0",
+ "@tanstack/vite-config": "catalog:",
"@types/babel__code-frame": "^7.0.6",
"@types/babel__core": "^7.20.5",
"@types/node": ">=20",
"@types/picomatch": "^4.0.2",
- "vite": "*",
- "@tanstack/vite-config": "catalog:",
- "vitest": "catalog:",
- "@arethetypeswrong/cli": "catalog:",
+ "fast-check": "^4.9.0",
"publint": "catalog:",
- "rimraf": "catalog:"
+ "rimraf": "catalog:",
+ "vite": "*",
+ "vitest": "catalog:"
},
"peerDependencies": {
"@rsbuild/core": "^2.0.0",
diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules-roundtrip.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules-roundtrip.test.ts
new file mode 100644
index 00000000000..e5b239d5fb1
--- /dev/null
+++ b/packages/start-plugin-core/tests/importProtection/virtualModules-roundtrip.test.ts
@@ -0,0 +1,159 @@
+import fc from 'fast-check'
+import { describe, expect, test } from 'vitest'
+import {
+ MOCK_EDGE_PREFIX,
+ MOCK_MODULE_ID,
+ MOCK_RUNTIME_PREFIX,
+ loadMockEdgeModule,
+ loadMockRuntimeModule,
+ makeMockEdgeModuleId,
+ mockRuntimeModuleIdFromViolation,
+} from '../../src/import-protection/virtualModules'
+import type { ViolationInfo } from '../../src/import-protection/trace'
+
+/**
+ * Build-time virtual-module IDs embed JSON payloads as base64url. These are
+ * developer-controlled inputs today, but decoded content flows into module
+ * graph IDs and generated code, so the round-trip must be exact and hostile
+ * payloads must degrade gracefully rather than throwing.
+ */
+
+function decodePayload(id: string): any {
+ // strip everything up to and including the final prefix colon
+ const encoded = id.slice(id.lastIndexOf(':') + 1)
+ return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
+}
+
+const violationArb = fc.record({
+ env: fc.constantFrom('client', 'server'),
+ importer: fc.string({ maxLength: 40 }),
+ specifier: fc.string({ maxLength: 40 }),
+ trace: fc.array(
+ fc.record({
+ file: fc.string({ maxLength: 20 }),
+ line: fc.option(fc.integer({ min: 1, max: 9999 }), { nil: null }),
+ column: fc.option(fc.integer({ min: 1, max: 200 }), { nil: null }),
+ }),
+ { maxLength: 3 },
+ ),
+})
+
+describe('virtual module id round-trips', () => {
+ test('runtime violation ids survive encode → decode exactly', () => {
+ fc.assert(
+ fc.property(
+ violationArb,
+ fc.constantFrom('error', 'warn'),
+ fc.string({ maxLength: 20 }),
+ (info, mode, root) => {
+ const id = mockRuntimeModuleIdFromViolation(
+ info as unknown as ViolationInfo,
+ mode,
+ root,
+ )
+
+ if (info.env !== 'client') {
+ // server-side violations get the static mock module - no payload
+ expect(id).toBe(MOCK_MODULE_ID)
+ return
+ }
+
+ expect(id.startsWith(MOCK_RUNTIME_PREFIX)).toBe(true)
+
+ const payload = decodePayload(id)
+ expect(payload.mode).toBe(mode)
+ expect(payload.env).toBe(info.env)
+ expect(payload.importer).toBe(info.importer)
+ expect(payload.specifier).toBe(info.specifier)
+ // trace file paths are relativized against root, so only the
+ // count is asserted here; content transformation is covered by
+ // the trace unit tests
+ expect(payload.trace).toHaveLength(info.trace.length)
+
+ // generating diagnostics code for the id must not throw
+ expect(() =>
+ loadMockRuntimeModule(id.slice(MOCK_RUNTIME_PREFIX.length)),
+ ).not.toThrow()
+ },
+ ),
+ )
+ })
+
+ test('edge module ids preserve export lists through decode', () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.string({ minLength: 1, maxLength: 12 }), { maxLength: 5 }),
+ fc.string({ minLength: 1, maxLength: 30 }),
+ (exports, runtimeId) => {
+ const id = makeMockEdgeModuleId(exports, runtimeId)
+ expect(id.startsWith(MOCK_EDGE_PREFIX)).toBe(true)
+
+ const payload = decodePayload(id)
+ expect(payload.exports).toEqual(
+ exports.filter((n) => n !== 'default'),
+ )
+ expect(payload.runtimeId).toBe(runtimeId)
+
+ expect(() =>
+ loadMockEdgeModule(id.slice(MOCK_EDGE_PREFIX.length)),
+ ).not.toThrow()
+ },
+ ),
+ )
+ })
+
+ test('distinct violations produce distinct module ids', () => {
+ const a = mockRuntimeModuleIdFromViolation(
+ makeViolation('/src/a.ts', 'process.env'),
+ 'error',
+ '/',
+ )
+ const b = mockRuntimeModuleIdFromViolation(
+ makeViolation('/src/b.ts', 'process.env'),
+ 'error',
+ '/',
+ )
+ expect(a).not.toBe(b)
+ })
+
+ test('unicode and quote-laden paths survive the base64url round-trip', () => {
+ const importer = '/src/café/"quoted"/\\backslash\\日本語.ts'
+ const id = mockRuntimeModuleIdFromViolation(
+ makeViolation(importer, 'window'),
+ 'error',
+ '/',
+ )
+ const payload = decodePayload(id)
+ expect(payload.importer).toBe(importer)
+ })
+
+ test('hostile payloads fall back to safe defaults instead of throwing', () => {
+ // garbage base64 / non-JSON payload
+ expect(() =>
+ loadMockEdgeModule(Buffer.from('not json').toString('base64url')),
+ ).not.toThrow()
+ expect(() => loadMockEdgeModule('!!!not-base64!!!')).not.toThrow()
+ // JSON of the wrong shape still yields a defined module
+ const wrongShape = Buffer.from('"just a string"').toString('base64url')
+ const result = loadMockEdgeModule(wrongShape)
+ expect(result.code).toBeDefined()
+ // mode values outside the allowlist clamp to "error"
+ const badMode = Buffer.from(JSON.stringify({ mode: 'arbitrary' })).toString(
+ 'base64url',
+ )
+ const runtime = loadMockRuntimeModule(badMode)
+ expect(runtime.code).toBeDefined()
+ expect(MOCK_MODULE_ID).toBeDefined()
+ })
+})
+
+type ViolationInfoLike = {
+ env: string
+ importer: string
+ specifier: string
+ trace: Array<{ file: string; line?: number | null; column?: number | null }>
+}
+
+function makeViolation(importer: string, specifier: string): ViolationInfoLike {
+ return { env: 'client', importer, specifier, trace: [] }
+}
diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts
index e245a132493..d89e5cad369 100644
--- a/packages/start-plugin-core/tests/prerender-ssrf.test.ts
+++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts
@@ -88,4 +88,41 @@ describe('prerender pages validation', () => {
await expect(prerender({ startConfig, handler })).resolves.not.toThrow()
expect(fetchMock).not.toHaveBeenCalled()
})
+
+ it('rejects protocol-relative page paths (//host) to avoid SSRF', async () => {
+ resetFetch()
+ const startConfig = makeStartConfig('//evil.test/leak')
+
+ await expect(prerender({ startConfig, handler })).rejects.toThrow(
+ /prerender page path must be relative/i,
+ )
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects other-origin page paths even with benign-looking schemes', async () => {
+ for (const hostile of [
+ 'https:evil.test',
+ 'HTTP://EVIL.TEST',
+ '/\\evil.test',
+ ]) {
+ resetFetch()
+ const startConfig = makeStartConfig(hostile)
+ // note: new URL() normalizes backslashes to slashes, so '/\evil.test'
+ // resolves against the base and is allowed; document actual behavior
+ try {
+ await prerender({ startConfig, handler })
+ // if it did not throw, ensure no request left localhost
+ const calledUrls = fetchMock.mock.calls.map((c) => String(c[0]))
+ for (const u of calledUrls) {
+ expect(u).toMatch(/^https:\/\/attacker\.test|^http:\/\/localhost/)
+ }
+ } catch (err) {
+ expect(String(err)).toMatch(/prerender page path must be relative/i)
+ }
+ expect(fetchMock).not.toHaveBeenCalledWith(
+ expect.stringContaining('evil.test'),
+ expect.anything(),
+ )
+ }
+ })
})
diff --git a/packages/start-server-core/package.json b/packages/start-server-core/package.json
index cd13db4b672..8b5e91dae77 100644
--- a/packages/start-server-core/package.json
+++ b/packages/start-server-core/package.json
@@ -108,15 +108,16 @@
"seroval": "^1.6.2"
},
"devDependencies": {
+ "@arethetypeswrong/cli": "catalog:",
"@standard-schema/spec": "^1.0.0",
- "cookie-es": "^3.0.0",
- "vite": "*",
- "@types/node": ">=20",
"@tanstack/vite-config": "catalog:",
- "vitest": "catalog:",
+ "@types/node": ">=20",
+ "cookie-es": "^3.0.0",
+ "fast-check": "^4.9.0",
"jsdom": "catalog:",
- "@arethetypeswrong/cli": "catalog:",
"publint": "catalog:",
- "rimraf": "catalog:"
+ "rimraf": "catalog:",
+ "vite": "*",
+ "vitest": "catalog:"
}
}
diff --git a/packages/start-server-core/tests/early-hints-hardening.test.ts b/packages/start-server-core/tests/early-hints-hardening.test.ts
new file mode 100644
index 00000000000..d90963c028d
--- /dev/null
+++ b/packages/start-server-core/tests/early-hints-hardening.test.ts
@@ -0,0 +1,86 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { serializeEarlyHint } from '../src/early-hints'
+
+/**
+ * Early Hints are emitted as HTTP `Link` headers. Attribute values come from
+ * the asset manifest / route `head` config (developer-controlled today), but
+ * a hostile value must still not be able to forge additional Link params.
+ * The contract enforced by buildLinkParam:
+ * - token-safe values are emitted unquoted
+ * - everything else is emitted as a JSON quoted-string (no raw quotes/`;`)
+ * - href is interpolated between <...> as-is (manifest-controlled; documented)
+ */
+
+// [hint property key, wire param name]
+const paramNames = [
+ ['as', 'as'],
+ ['type', 'type'],
+ ['integrity', 'integrity'],
+ ['referrerPolicy', 'referrerpolicy'],
+ ['fetchPriority', 'fetchpriority'],
+] as const
+
+describe('serializeEarlyHint injection resistance', () => {
+ it('token values pass through; non-token values become quoted strings', () => {
+ fc.assert(
+ fc.property(
+ fc.constantFrom(...paramNames),
+ fc.string({ maxLength: 60 }),
+ ([key, name], value) => {
+ const hint = { rel: 'preload', href: '/x.js', [key]: value }
+ const out = serializeEarlyHint(hint as any)
+ const tokenRe = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+ if (value !== '' && tokenRe.test(value)) {
+ // safe token: emitted verbatim
+ expect(out).toContain(`${name}=${value}`)
+ } else {
+ // falsy values are omitted; non-token values MUST be quoted.
+ // Invariant: every occurrence of `name=` is either inside a
+ // quoted section or immediately followed by the opening quote.
+ const outsideQuotes = out.replace(/"(?:[^"\\]|\\.)*"/g, '')
+ expect(outsideQuotes).not.toMatch(new RegExp(`${name}=[^"]`))
+ }
+ },
+ ),
+ )
+ })
+
+ it('a hostile attribute value cannot forge extra Link parameters', () => {
+ const evil = 'preload; rel=stylesheet; foo=bar'
+ const out = serializeEarlyHint({
+ rel: 'preload',
+ href: '/x.js',
+ integrity: evil,
+ } as any)
+ // the hostile string only appears inside the quoted integrity value;
+ // everything outside quoted sections must be free of injected params
+ const outsideQuotes = out.replace(/"(?:[^"\\]|\\.)*"/g, '')
+ expect(outsideQuotes).not.toContain('rel=stylesheet')
+ expect(outsideQuotes).not.toContain('foo=bar')
+ })
+
+ it('undefined optional params are omitted, falsy crossorigin normalizes', () => {
+ const out = serializeEarlyHint({ rel: 'preload', href: '/a.js' } as any)
+ expect(out).toBe('; rel=preload')
+
+ const out2 = serializeEarlyHint({
+ rel: 'preload',
+ href: '/a.js',
+ crossOrigin: '',
+ } as any)
+ expect(out2).toContain('crossorigin')
+ })
+
+ it('DOCUMENTED QUIRK: href is not sanitized (manifest-controlled input)', () => {
+ // href originates from the asset manifest, not request input. If that
+ // ever changes to include user data, this interpolation between <...>
+ // becomes a header-injection vector and must gain escaping first.
+ const out = serializeEarlyHint({
+ rel: 'preload',
+ href: '/a b.js; rel=evil',
+ } as any)
+ expect(out).toContain('')
+ })
+})
diff --git a/packages/start-server-core/tests/server-functions-handler.test.ts b/packages/start-server-core/tests/server-functions-handler.test.ts
new file mode 100644
index 00000000000..161b9f0221d
--- /dev/null
+++ b/packages/start-server-core/tests/server-functions-handler.test.ts
@@ -0,0 +1,266 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ TSS_FORMDATA_CONTEXT,
+ X_TSS_SERIALIZED,
+} from '@tanstack/start-client-core'
+
+/**
+ * Tests for the untrusted-payload handling in handleServerAction.
+ *
+ * The server functions handler deserializes attacker-controlled request
+ * bodies (seroval.fromJSON over JSON from the query string, POST body or
+ * multipart form data). These tests pin the contract:
+ * - oversized GET payloads are rejected before reaching the server function
+ * - malformed payloads produce an error response, never a crash
+ * - the server function's declared HTTP method is enforced before parsing
+ * - server context always wins over client-supplied context
+ * - malformed FormData context falls back to base context instead of failing
+ */
+
+const mockAction = vi.fn(async (params: any) => ({
+ ok: true,
+ echo: params?.data ?? null,
+}))
+
+vi.mock('#tanstack-start-server-fn-resolver', () => ({
+ getServerFnById: () => mockAction,
+}))
+
+import { toJSONAsync } from 'seroval'
+import { handleServerAction } from '../src/server-functions-handler'
+import { runWithStartContext } from '@tanstack/start-storage-context'
+import type { StartStorageContext } from '@tanstack/start-storage-context'
+
+// handleServerAction reads response state from the request/response
+// AsyncLocalStorage (global symbol) and options from the start storage
+// context. Provide minimal versions of both.
+const EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage')
+const eventStorage = (globalThis as any)[EVENT_STORAGE_KEY] as
+ | AsyncLocalStorage<{ h3Event: any }>
+ | undefined
+
+const fakeH3Event = {
+ res: {
+ status: undefined as number | undefined,
+ statusText: '',
+ headers: new Headers(),
+ },
+}
+
+const fakeStartContext = {
+ getRouter: () => {
+ throw new Error('not needed in this test')
+ },
+ request: new Request('http://localhost/'),
+ startOptions: { serializationAdapters: [] },
+ contextAfterGlobalMiddlewares: {},
+ executedRequestMiddlewares: new Set(),
+ handlerType: 'serverFn',
+} as unknown as StartStorageContext
+
+async function withStartContext(fn: () => Promise): Promise {
+ if (!eventStorage) throw new Error('Start event storage not initialized')
+ return runWithStartContext(fakeStartContext, () =>
+ eventStorage.run({ h3Event: fakeH3Event }, fn),
+ )
+}
+
+beforeEach(() => {
+ mockAction.mockClear()
+ mockAction.mockImplementation(async (params: any) => ({
+ ok: true,
+ echo: params?.data ?? null,
+ }))
+ delete (mockAction as any).method
+ fakeH3Event.res.status = undefined
+})
+
+describe('handleServerAction payload hardening', () => {
+ it('rejects oversized GET payloads without invoking the server function', async () => {
+ await withStartContext(async () => {
+ const oversized = 'x'.repeat(1_000_001)
+ const url = new URL('http://localhost/_serverFn/x')
+ url.searchParams.set('payload', JSON.stringify({ data: oversized }))
+ const request = new Request(url, {
+ method: 'GET',
+ headers: { 'x-tsr-serverFn': 'true' },
+ })
+
+ const res = await handleServerAction({
+ request,
+ context: {},
+ serverFnId: 'test',
+ })
+
+ expect(mockAction).not.toHaveBeenCalled()
+ expect(res).toBeInstanceOf(Response)
+ expect(res!.status).toBe(500)
+ })
+ })
+
+ it('parses valid GET payloads and merges server context over client context', async () => {
+ await withStartContext(async () => {
+ const url = new URL('http://localhost/_serverFn/x')
+ // the client sends seroval JSON, not plain JSON - mirror that here
+ const payload = JSON.stringify(
+ await toJSONAsync({ data: 42, context: { c: 'client' } }),
+ )
+ url.searchParams.set('payload', payload)
+ const request = new Request(url, {
+ method: 'GET',
+ headers: { 'x-tsr-serverFn': 'true' },
+ })
+
+ const res = await handleServerAction({
+ request,
+ context: { c: 'server', s: true },
+ serverFnId: 'test',
+ })
+
+ expect(res!.status).toBe(200)
+ expect(res!.headers.get(X_TSS_SERIALIZED)).toBe('true')
+ const params = mockAction.mock.calls[0]![0]
+ expect(params.data).toBe(42)
+ expect(params.context).toEqual({ c: 'server', s: true })
+ expect(params.method).toBe('GET')
+ })
+ })
+
+ it('malformed GET payload JSON produces an error response, not a crash', async () => {
+ await withStartContext(async () => {
+ const url = new URL('http://localhost/_serverFn/x')
+ url.searchParams.set('payload', '{"data": not-json}')
+ const request = new Request(url, {
+ method: 'GET',
+ headers: { 'x-tsr-serverFn': 'true' },
+ })
+
+ const res = await handleServerAction({
+ request,
+ context: {},
+ serverFnId: 'test',
+ })
+ expect(mockAction).not.toHaveBeenCalled()
+ expect(res!.status).toBe(500)
+ })
+ })
+
+ it('malformed POST JSON body produces an error response', async () => {
+ await withStartContext(async () => {
+ const request = new Request('http://localhost/_serverFn/x', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-tsr-serverFn': 'true',
+ },
+ body: '{invalid',
+ })
+
+ const res = await handleServerAction({
+ request,
+ context: {},
+ serverFnId: 'test',
+ })
+ expect(mockAction).not.toHaveBeenCalled()
+ expect(res!.status).toBe(500)
+ })
+ })
+
+ it('round-trips POST JSON payloads through seroval', async () => {
+ await withStartContext(async () => {
+ const request = new Request('http://localhost/_serverFn/x', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-tsr-serverFn': 'true',
+ },
+ body: JSON.stringify(
+ await toJSONAsync({ data: { nested: [1, 'two', null] } }),
+ ),
+ })
+
+ const res = await handleServerAction({
+ request,
+ context: {},
+ serverFnId: 'test',
+ })
+ expect(res!.status).toBe(200)
+ const params = mockAction.mock.calls[0]![0]
+ expect(params.data).toEqual({ nested: [1, 'two', null] })
+ })
+ })
+
+ it('rejects requests whose method mismatches the declared fn method before parsing', async () => {
+ await withStartContext(async () => {
+ ;(mockAction as any).method = 'POST'
+
+ // an oversized payload would also fail, but the 405 must win by arriving first
+ const url = new URL('http://localhost/_serverFn/x')
+ url.searchParams.set('payload', 'x'.repeat(1_000_001))
+ const request = new Request(url, { method: 'GET' })
+
+ const res = await handleServerAction({
+ request,
+ context: {},
+ serverFnId: 'test',
+ })
+ expect(res!.status).toBe(405)
+ expect(res!.headers.get('Allow')).toBe('POST')
+ expect(mockAction).not.toHaveBeenCalled()
+ })
+ })
+
+ it('malformed FormData context falls back to base context', async () => {
+ await withStartContext(async () => {
+ const form = new FormData()
+ form.append(TSS_FORMDATA_CONTEXT, '{broken json')
+ form.append('data', 'hello')
+
+ // note: do not set Content-Type manually - the boundary must come from
+ // the Request's own FormData serialization
+ const request = new Request('http://localhost/_serverFn/x', {
+ method: 'POST',
+ headers: { 'x-tsr-serverFn': 'true' },
+ body: form,
+ })
+
+ await handleServerAction({
+ request,
+ context: { base: true },
+ serverFnId: 'test',
+ })
+
+ const params = mockAction.mock.calls[0]![0]
+ expect(params.data.get('data')).toBe('hello')
+ expect(params.context).toEqual({ base: true })
+ })
+ })
+
+ it('client-supplied object context never overrides server context keys', async () => {
+ await withStartContext(async () => {
+ const form = new FormData()
+ form.append(
+ TSS_FORMDATA_CONTEXT,
+ JSON.stringify(await toJSONAsync({ evil: 'yes', keep: 1 })),
+ )
+ form.append('data', 'hello')
+
+ const request = new Request('http://localhost/_serverFn/x', {
+ method: 'POST',
+ headers: { 'x-tsr-serverFn': 'true' },
+ body: form,
+ })
+
+ await handleServerAction({
+ request,
+ context: { evil: 'no' },
+ serverFnId: 'test',
+ })
+
+ const params = mockAction.mock.calls[0]![0]
+ expect(params.context.evil).toBe('no')
+ expect(params.context.keep).toBe(1)
+ expect(params.context.base).toBeUndefined()
+ })
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5845bfb6b3..4478e6fb4b3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -211,7 +211,7 @@ importers:
version: typescript@7.0.2
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.5(supports-color@10.2.2))(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: ^6.0.5
version: 6.0.5(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))
@@ -360,7 +360,7 @@ importers:
version: typescript@7.0.2
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.5(supports-color@10.2.2))(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: ^6.0.5
version: 6.0.5(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))
@@ -14166,6 +14166,9 @@ importers:
esbuild:
specifier: ^0.27.4
version: 0.27.4
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
jsdom:
specifier: 'catalog:'
version: 25.0.1(supports-color@10.2.2)
@@ -14854,6 +14857,9 @@ importers:
'@types/node':
specifier: 25.0.9
version: 25.0.9
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
jsdom:
specifier: 'catalog:'
version: 25.0.1(supports-color@10.2.2)
@@ -14975,6 +14981,9 @@ importers:
'@types/picomatch':
specifier: ^4.0.2
version: 4.0.2
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
publint:
specifier: 'catalog:'
version: 0.3.17
@@ -15027,6 +15036,9 @@ importers:
cookie-es:
specifier: ^3.0.0
version: 3.1.1
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
jsdom:
specifier: 'catalog:'
version: 25.0.1(supports-color@10.2.2)
@@ -24092,6 +24104,7 @@ packages:
eslint@9.22.0:
resolution: {integrity: sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -24190,6 +24203,10 @@ packages:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
+ fast-check@4.9.0:
+ resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==}
+ engines: {node: '>=12.17.0'}
+
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
@@ -26598,6 +26615,9 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+ pure-rand@8.4.2:
+ resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
+
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
@@ -26889,11 +26909,6 @@ packages:
resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==}
engines: {node: '>=10'}
- resolve@1.22.10:
- resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
- engines: {node: '>= 0.4'}
- hasBin: true
-
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -35906,7 +35921,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vitest: 4.1.4(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.7.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ vitest: 4.1.4(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@25.0.1(supports-color@10.2.2))(msw@2.7.0(@types/node@25.0.9)(@typescript/typescript6@6.0.2))(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/utils@4.1.4':
dependencies:
@@ -36372,6 +36387,10 @@ snapshots:
optionalDependencies:
ajv: 8.17.1
+ ajv-formats@2.1.1(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
ajv-formats@3.0.1(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -36389,6 +36408,11 @@ snapshots:
ajv: 8.17.1
fast-deep-equal: 3.1.3
+ ajv-keywords@5.1.0(ajv@8.18.0):
+ dependencies:
+ ajv: 8.18.0
+ fast-deep-equal: 3.1.3
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -38403,7 +38427,7 @@ snapshots:
etag: 1.8.1
finalhandler: 1.3.1(supports-color@10.2.2)
fresh: 0.5.2
- http-errors: 2.0.0
+ http-errors: 2.0.1
merge-descriptors: 1.0.3
methods: 1.1.2
on-finished: 2.4.1
@@ -38416,7 +38440,7 @@ snapshots:
send: 0.19.0(supports-color@10.2.2)
serve-static: 1.16.2(supports-color@10.2.2)
setprototypeof: 1.2.0
- statuses: 2.0.1
+ statuses: 2.0.2
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
@@ -38476,6 +38500,10 @@ snapshots:
dependencies:
pure-rand: 6.1.0
+ fast-check@4.9.0:
+ dependencies:
+ pure-rand: 8.4.2
+
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
@@ -41278,6 +41306,8 @@ snapshots:
pure-rand@6.1.0: {}
+ pure-rand@8.4.2: {}
+
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
@@ -41532,7 +41562,7 @@ snapshots:
rechoir@0.8.0:
dependencies:
- resolve: 1.22.10
+ resolve: 1.22.11
redaxios@0.5.1: {}
@@ -41610,12 +41640,6 @@ snapshots:
resolve.exports@2.0.3: {}
- resolve@1.22.10:
- dependencies:
- is-core-module: 2.16.1
- path-parse: 1.0.7
- supports-preserve-symlinks-flag: 1.0.0
-
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -41835,9 +41859,9 @@ snapshots:
schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
- ajv: 8.17.1
- ajv-formats: 2.1.1(ajv@8.17.1)
- ajv-keywords: 5.1.0(ajv@8.17.1)
+ ajv: 8.18.0
+ ajv-formats: 2.1.1(ajv@8.18.0)
+ ajv-keywords: 5.1.0(ajv@8.18.0)
scule@1.3.0: {}