@pyreon/server 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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +4 -3
- package/package.json +7 -7
- package/src/html.ts +11 -3
- package/src/ssg.ts +11 -2
- package/src/tests/server.test.ts +43 -0
|
@@ -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":"
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.20.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.
|
|
53
|
-
"@pyreon/head": "^0.
|
|
54
|
-
"@pyreon/reactivity": "^0.
|
|
55
|
-
"@pyreon/router": "^0.
|
|
56
|
-
"@pyreon/runtime-dom": "^0.
|
|
57
|
-
"@pyreon/runtime-server": "^0.
|
|
52
|
+
"@pyreon/core": "^0.20.0",
|
|
53
|
+
"@pyreon/head": "^0.20.0",
|
|
54
|
+
"@pyreon/reactivity": "^0.20.0",
|
|
55
|
+
"@pyreon/router": "^0.20.0",
|
|
56
|
+
"@pyreon/runtime-dom": "^0.20.0",
|
|
57
|
+
"@pyreon/runtime-server": "^0.20.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
|
-
|
|
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
|
}
|
package/src/tests/server.test.ts
CHANGED
|
@@ -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 }] })
|