@pyreon/server 0.16.0 → 0.19.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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"22c74963-1","name":"html.ts"},{"uid":"22c74963-3","name":"middleware.ts"},{"uid":"22c74963-5","name":"handler.ts"},{"uid":"22c74963-7","name":"island.ts"},{"uid":"22c74963-9","name":"ssg.ts"},{"uid":"22c74963-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"22c74963-1":{"renderedLength":2752,"gzipLength":1146,"brotliLength":0,"metaUid":"22c74963-0"},"22c74963-3":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"22c74963-2"},"22c74963-5":{"renderedLength":3356,"gzipLength":1446,"brotliLength":0,"metaUid":"22c74963-4"},"22c74963-7":{"renderedLength":2957,"gzipLength":1377,"brotliLength":0,"metaUid":"22c74963-6"},"22c74963-9":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"22c74963-8"},"22c74963-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"22c74963-10"}},"nodeMetas":{"22c74963-0":{"id":"/src/html.ts","moduleParts":{"index.js":"22c74963-1"},"imported":[{"uid":"22c74963-14"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-2":{"id":"/src/middleware.ts","moduleParts":{"index.js":"22c74963-3"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-4":{"id":"/src/handler.ts","moduleParts":{"index.js":"22c74963-5"},"imported":[{"uid":"22c74963-12"},{"uid":"22c74963-13"},{"uid":"22c74963-14"},{"uid":"22c74963-15"},{"uid":"22c74963-0"},{"uid":"22c74963-2"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-6":{"id":"/src/island.ts","moduleParts":{"index.js":"22c74963-7"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-8":{"id":"/src/ssg.ts","moduleParts":{"index.js":"22c74963-9"},"imported":[{"uid":"22c74963-16"},{"uid":"22c74963-17"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-10":{"id":"/src/index.ts","moduleParts":{"index.js":"22c74963-11"},"imported":[{"uid":"22c74963-4"},{"uid":"22c74963-0"},{"uid":"22c74963-6"},{"uid":"22c74963-2"},{"uid":"22c74963-8"}],"importedBy":[],"isEntry":true},"22c74963-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-6"},{"uid":"22c74963-2"}]},"22c74963-13":{"id":"@pyreon/head/ssr","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-14":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-0"}]},"22c74963-15":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-16":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]},"22c74963-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"4deb8524-1","name":"html.ts"},{"uid":"4deb8524-3","name":"middleware.ts"},{"uid":"4deb8524-5","name":"handler.ts"},{"uid":"4deb8524-7","name":"island.ts"},{"uid":"4deb8524-9","name":"ssg.ts"},{"uid":"4deb8524-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"4deb8524-1":{"renderedLength":2770,"gzipLength":1153,"brotliLength":0,"metaUid":"4deb8524-0"},"4deb8524-3":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"4deb8524-2"},"4deb8524-5":{"renderedLength":3356,"gzipLength":1446,"brotliLength":0,"metaUid":"4deb8524-4"},"4deb8524-7":{"renderedLength":2957,"gzipLength":1377,"brotliLength":0,"metaUid":"4deb8524-6"},"4deb8524-9":{"renderedLength":2881,"gzipLength":1235,"brotliLength":0,"metaUid":"4deb8524-8"},"4deb8524-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"4deb8524-10"}},"nodeMetas":{"4deb8524-0":{"id":"/src/html.ts","moduleParts":{"index.js":"4deb8524-1"},"imported":[{"uid":"4deb8524-14"}],"importedBy":[{"uid":"4deb8524-10"},{"uid":"4deb8524-4"}]},"4deb8524-2":{"id":"/src/middleware.ts","moduleParts":{"index.js":"4deb8524-3"},"imported":[{"uid":"4deb8524-12"}],"importedBy":[{"uid":"4deb8524-10"},{"uid":"4deb8524-4"}]},"4deb8524-4":{"id":"/src/handler.ts","moduleParts":{"index.js":"4deb8524-5"},"imported":[{"uid":"4deb8524-12"},{"uid":"4deb8524-13"},{"uid":"4deb8524-14"},{"uid":"4deb8524-15"},{"uid":"4deb8524-0"},{"uid":"4deb8524-2"}],"importedBy":[{"uid":"4deb8524-10"}]},"4deb8524-6":{"id":"/src/island.ts","moduleParts":{"index.js":"4deb8524-7"},"imported":[{"uid":"4deb8524-12"}],"importedBy":[{"uid":"4deb8524-10"}]},"4deb8524-8":{"id":"/src/ssg.ts","moduleParts":{"index.js":"4deb8524-9"},"imported":[{"uid":"4deb8524-16"},{"uid":"4deb8524-17"}],"importedBy":[{"uid":"4deb8524-10"}]},"4deb8524-10":{"id":"/src/index.ts","moduleParts":{"index.js":"4deb8524-11"},"imported":[{"uid":"4deb8524-4"},{"uid":"4deb8524-0"},{"uid":"4deb8524-6"},{"uid":"4deb8524-2"},{"uid":"4deb8524-8"}],"importedBy":[],"isEntry":true},"4deb8524-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-4"},{"uid":"4deb8524-6"},{"uid":"4deb8524-2"}]},"4deb8524-13":{"id":"@pyreon/head/ssr","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-4"}]},"4deb8524-14":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-4"},{"uid":"4deb8524-0"}]},"4deb8524-15":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-4"}]},"4deb8524-16":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-8"}]},"4deb8524-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"4deb8524-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { renderWithHead } from "@pyreon/head/ssr";
3
3
  import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from "@pyreon/router";
4
4
  import { renderToStream, runWithRequestContext } from "@pyreon/runtime-server";
5
5
  import { mkdir, writeFile } from "node:fs/promises";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { dirname, join, resolve, sep } from "node:path";
7
7
 
8
8
  //#region src/html.ts
9
9
  /**
@@ -44,7 +44,7 @@ function splitOnce(str, delimiter) {
44
44
  return [str.slice(0, idx), str.slice(idx + delimiter.length)];
45
45
  }
46
46
  function processTemplate(template, data) {
47
- return template.replace("<!--pyreon-head-->", data.head).replace("<!--pyreon-app-->", data.app).replace("<!--pyreon-scripts-->", data.scripts);
47
+ return template.replace("<!--pyreon-head-->", () => data.head).replace("<!--pyreon-app-->", () => data.app).replace("<!--pyreon-scripts-->", () => data.scripts);
48
48
  }
49
49
  /** Fast path using a pre-compiled template */
50
50
  function processCompiledTemplate(compiled, data) {
@@ -385,7 +385,8 @@ async function prerender(options) {
385
385
  }
386
386
  const filePath = resolveOutputPath(outDir, path);
387
387
  const resolvedOut = resolve(outDir);
388
- if (!resolve(filePath).startsWith(resolvedOut)) {
388
+ const resolvedFile = resolve(filePath);
389
+ if (resolvedFile !== resolvedOut && !resolvedFile.startsWith(resolvedOut + sep)) {
389
390
  errors.push({
390
391
  path,
391
392
  error: /* @__PURE__ */ new Error(`Path traversal detected: "${path}"`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/server",
3
- "version": "0.16.0",
3
+ "version": "0.19.0",
4
4
  "description": "SSR handler, SSG prerender, and island architecture for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/server#readme",
6
6
  "bugs": {
@@ -49,12 +49,12 @@
49
49
  "prepublishOnly": "bun run build"
50
50
  },
51
51
  "dependencies": {
52
- "@pyreon/core": "^0.16.0",
53
- "@pyreon/head": "^0.16.0",
54
- "@pyreon/reactivity": "^0.16.0",
55
- "@pyreon/router": "^0.16.0",
56
- "@pyreon/runtime-dom": "^0.16.0",
57
- "@pyreon/runtime-server": "^0.16.0"
52
+ "@pyreon/core": "^0.19.0",
53
+ "@pyreon/head": "^0.19.0",
54
+ "@pyreon/reactivity": "^0.19.0",
55
+ "@pyreon/router": "^0.19.0",
56
+ "@pyreon/runtime-dom": "^0.19.0",
57
+ "@pyreon/runtime-server": "^0.19.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@pyreon/manifest": "0.13.1",
package/src/html.ts CHANGED
@@ -53,10 +53,18 @@ function splitOnce(str: string, delimiter: string): [string, string] {
53
53
  }
54
54
 
55
55
  export function processTemplate(template: string, data: TemplateData): string {
56
+ // Use FUNCTION replacements, not string replacements. With a string
57
+ // replacement, `String.prototype.replace` still interprets `$$`, `$&`,
58
+ // `` $` ``, `$'`, `$n` in the *replacement* even though the search is a
59
+ // literal string. `data.app` is rendered SSR HTML and routinely
60
+ // contains literal `$&` / `$$` (prices, code samples, math) — string
61
+ // replacement would corrupt them. A replacer function returns its
62
+ // value verbatim with zero `$`-pattern interpretation. Same
63
+ // first-occurrence semantics as before.
56
64
  return template
57
- .replace('<!--pyreon-head-->', data.head)
58
- .replace('<!--pyreon-app-->', data.app)
59
- .replace('<!--pyreon-scripts-->', data.scripts)
65
+ .replace('<!--pyreon-head-->', () => data.head)
66
+ .replace('<!--pyreon-app-->', () => data.app)
67
+ .replace('<!--pyreon-scripts-->', () => data.scripts)
60
68
  }
61
69
 
62
70
  /** Fast path using a pre-compiled template */
package/src/ssg.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  */
30
30
 
31
31
  import { mkdir, writeFile } from 'node:fs/promises'
32
- import { dirname, join, resolve } from 'node:path'
32
+ import { dirname, join, resolve, sep } from 'node:path'
33
33
 
34
34
  export interface PrerenderOptions {
35
35
  /** SSR handler created by createHandler() */
@@ -102,8 +102,17 @@ export async function prerender(options: PrerenderOptions): Promise<PrerenderRes
102
102
 
103
103
  const filePath = resolveOutputPath(outDir, path)
104
104
 
105
+ // Containment check must be separator-terminated. A bare
106
+ // `startsWith(resolve(outDir))` is a string-prefix test, not a
107
+ // path-containment test: with outDir `/app/dist`, a traversed
108
+ // `filePath` resolving to `/app/dist-secret/x` passes
109
+ // `'/app/dist-secret/x'.startsWith('/app/dist')` → true, and the
110
+ // build writes (possibly secret-bearing) HTML to a SIBLING dir
111
+ // outside the intended output root. `path` derives from caller-
112
+ // supplied route params (e.g. CMS slugs via getStaticPaths).
105
113
  const resolvedOut = resolve(outDir)
106
- if (!resolve(filePath).startsWith(resolvedOut)) {
114
+ const resolvedFile = resolve(filePath)
115
+ if (resolvedFile !== resolvedOut && !resolvedFile.startsWith(resolvedOut + sep)) {
107
116
  errors.push({ path, error: new Error(`Path traversal detected: "${path}"`) })
108
117
  return
109
118
  }
@@ -32,6 +32,23 @@ describe('HTML template', () => {
32
32
  expect(result).not.toContain('<!--pyreon-scripts-->')
33
33
  })
34
34
 
35
+ test('processTemplate preserves literal $-sequences in rendered HTML (no regex-pattern corruption)', () => {
36
+ // Regression: `String.prototype.replace(str, str)` interprets `$$`,
37
+ // `$&`, `` $` ``, `$'`, `$n` in the REPLACEMENT even with a string
38
+ // search. Rendered SSR HTML routinely contains these (prices, code,
39
+ // math). Must round-trip verbatim.
40
+ const appHtml = 'Total: $$50 — match $& and back$`tick and $\' and $1 group'
41
+ const result = processTemplate(DEFAULT_TEMPLATE, {
42
+ head: 'price $& head',
43
+ app: appHtml,
44
+ scripts: '$$ scripts $\'',
45
+ })
46
+ expect(result).toContain(appHtml)
47
+ expect(result).toContain('price $& head')
48
+ expect(result).toContain("$$ scripts $'")
49
+ expect(result).not.toContain('<!--pyreon-app-->')
50
+ })
51
+
35
52
  test('buildScripts emits loader data + client entry', () => {
36
53
  const scripts = buildScripts('/entry.js', { users: [{ id: 1 }] })
37
54
  expect(scripts).toContain('window.__PYREON_LOADER_DATA__=')
@@ -757,6 +774,32 @@ describe('prerender', () => {
757
774
  await rm(tmpDir, { recursive: true, force: true })
758
775
  })
759
776
 
777
+ test('rejects a path that escapes outDir into a SIBLING dir (prefix-match bypass)', async () => {
778
+ // Regression: the guard was `resolve(filePath).startsWith(resolve(outDir))`
779
+ // — a string-prefix test. With outDir `/tmp/<base>`, a path resolving
780
+ // to the SIBLING `/tmp/<base>-evil/...` passes `startsWith` and the
781
+ // build writes HTML OUTSIDE the output root. `path` derives from
782
+ // caller route params (CMS slugs via getStaticPaths).
783
+ const App: ComponentFn = () => h('div', null, 'secret')
784
+ const handler = createHandler({ App, routes: [{ path: '/', component: App }] })
785
+ const stamp = Date.now()
786
+ const outDir = `/tmp/pyreon-ssgtrav-${stamp}`
787
+ const siblingEvil = `/tmp/pyreon-ssgtrav-${stamp}-evil`
788
+ const escapingPath = `/../pyreon-ssgtrav-${stamp}-evil`
789
+
790
+ const result = await prerender({ handler, paths: [escapingPath], outDir })
791
+
792
+ // The escaping path is rejected, recorded as an error, NOT written.
793
+ expect(result.pages).toBe(0)
794
+ expect(result.errors.some((e) => /Path traversal detected/.test(String(e.error)))).toBe(true)
795
+ const fs = await import('node:fs')
796
+ expect(fs.existsSync(`${siblingEvil}/index.html`)).toBe(false)
797
+
798
+ const { rm } = await import('node:fs/promises')
799
+ await rm(outDir, { recursive: true, force: true })
800
+ await rm(siblingEvil, { recursive: true, force: true })
801
+ })
802
+
760
803
  test('onPage callback can skip pages', async () => {
761
804
  const App: ComponentFn = () => h('div', null)
762
805
  const handler = createHandler({ App, routes: [{ path: '/', component: App }] })