@pyreon/zero 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ssg-plugin.ts CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { existsSync } from 'node:fs'
25
25
  import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
26
- import { dirname, join, resolve } from 'node:path'
26
+ import { dirname, join, resolve, sep } from 'node:path'
27
27
  import { pathToFileURL } from 'node:url'
28
28
  import type { Plugin } from 'vite'
29
29
  import { resolveAdapter } from './adapters'
@@ -373,6 +373,25 @@ export function expandUrlPattern(pattern: string, params: Record<string, string>
373
373
  `[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`,
374
374
  )
375
375
  }
376
+ // Path-escape guard. The value is substituted verbatim into the
377
+ // URL that becomes a `dist/<path>/index.html` write target. A
378
+ // single (non-catch-all) `:slug` is ONE segment — a value
379
+ // containing `/` or being `.`/`..` (e.g. an unsanitized CMS slug
380
+ // `../../secret`) would escape the intended structure and write
381
+ // outside it. Catch-all `:slug*` legitimately spans segments
382
+ // (`a/b/c`), so it's exempt from the `/` check but still rejects
383
+ // `.`/`..` traversal segments.
384
+ const segs = isCatchAll ? value.split('/') : [value]
385
+ if (
386
+ (!isCatchAll && value.includes('/')) ||
387
+ segs.some((s) => s === '.' || s === '..')
388
+ ) {
389
+ throw new Error(
390
+ `[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value ` +
391
+ `(${JSON.stringify(value)}): a ${isCatchAll ? 'catch-all' : 'dynamic'} segment ` +
392
+ `must not contain path-traversal ("." / "..")${isCatchAll ? '' : ' or "/"'}.`,
393
+ )
394
+ }
376
395
  return value
377
396
  })
378
397
  .join('/')
@@ -443,10 +462,17 @@ async function autoDetectStaticPaths(
443
462
  }
444
463
  }
445
464
 
465
+ // Dedup (order-preserving). The same concrete path can be produced
466
+ // more than once — a `getStaticPaths` returning a duplicate slug, or
467
+ // i18n route fan-out colliding — which otherwise renders the same
468
+ // `dist/<path>/index.html` twice (wasted work + last-write race) and
469
+ // feeds a duplicate `<url>` into the SSG→sitemap merge.
470
+ const deduped = [...new Set(out)]
471
+
446
472
  // Always include "/" as a fallback if no static routes were found —
447
473
  // a project with only dynamic routes still needs an index.html for the
448
474
  // host to know where to send unmatched URLs.
449
- return out.length > 0 ? out : ['/']
475
+ return deduped.length > 0 ? deduped : ['/']
450
476
  }
451
477
 
452
478
  async function resolvePaths(
@@ -598,6 +624,21 @@ function resolveOutputPath(distDir: string, path: string): string {
598
624
  return join(distDir, path, 'index.html')
599
625
  }
600
626
 
627
+ /**
628
+ * Path-containment check that is SEPARATOR-TERMINATED. A bare
629
+ * `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
630
+ * test, not a path test: with distDir `/app/dist`, a traversed filePath
631
+ * resolving to the SIBLING `/app/dist-evil/x` passes
632
+ * `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
633
+ * writes outside the intended output root. `path` derives from caller
634
+ * route params (CMS slugs via `getStaticPaths`), so this is reachable.
635
+ */
636
+ function isInsideDist(distDir: string, filePath: string): boolean {
637
+ const root = resolve(distDir)
638
+ const target = resolve(filePath)
639
+ return target === root || target.startsWith(root + sep)
640
+ }
641
+
601
642
  // ─── Redirect emission (PR B) ──────────────────────────────────────────────
602
643
  //
603
644
  // The shape returned by the SSG entry's renderPath when a loader throws
@@ -1128,8 +1169,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1128
1169
 
1129
1170
  if (config.ssg?.redirectsAsHtml === 'meta-refresh') {
1130
1171
  const filePath = resolveOutputPath(distDir, p)
1131
- const resolvedOut = resolve(distDir)
1132
- if (!resolve(filePath).startsWith(resolvedOut)) {
1172
+ if (!isInsideDist(distDir, filePath)) {
1133
1173
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1134
1174
  return
1135
1175
  }
@@ -1144,8 +1184,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1144
1184
  const filePath = resolveOutputPath(distDir, p)
1145
1185
 
1146
1186
  // Path-traversal guard — same as @pyreon/server's prerender.
1147
- const resolvedOut = resolve(distDir)
1148
- if (!resolve(filePath).startsWith(resolvedOut)) {
1187
+ if (!isInsideDist(distDir, filePath)) {
1149
1188
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1150
1189
  return
1151
1190
  }
@@ -1174,8 +1213,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1174
1213
  const fallbackHtml = await config.ssg.onPathError(p, error)
1175
1214
  if (typeof fallbackHtml === 'string') {
1176
1215
  const filePath = resolveOutputPath(distDir, p)
1177
- const resolvedOut = resolve(distDir)
1178
- if (!resolve(filePath).startsWith(resolvedOut)) {
1216
+ if (!isInsideDist(distDir, filePath)) {
1179
1217
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1180
1218
  return
1181
1219
  }
@@ -1505,6 +1543,7 @@ export const _internal = {
1505
1543
  resolvePaths,
1506
1544
  autoDetectStaticPaths,
1507
1545
  resolveOutputPath,
1546
+ isInsideDist,
1508
1547
  expandUrlPattern,
1509
1548
  injectIntoTemplate,
1510
1549
  renderNetlifyRedirects,
package/src/types.ts CHANGED
@@ -58,6 +58,15 @@ export interface ISRConfig {
58
58
  * space (e.g. `/user/:id` where `:id` is free-form).
59
59
  */
60
60
  maxEntries?: number
61
+ /**
62
+ * Max wall-time (ms) for a single background revalidation before it is
63
+ * abandoned. Without a bound, a handler that hangs leaves its key
64
+ * pinned in the in-flight set forever — every later request for that
65
+ * key short-circuits the de-dupe guard and the entry can never
66
+ * recover from stale. Default: `30000` (matches the Suspense
67
+ * streaming timeout).
68
+ */
69
+ revalidateTimeoutMs?: number
61
70
  /**
62
71
  * Cache-key derivation function. The default keys cache entries by
63
72
  * `url.pathname` ONLY — query strings, cookies, and headers are