dinou 4.0.11 → 4.0.13
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/CHANGELOG.md +26 -0
- package/dinou/core/client-webpack.jsx +17 -3
- package/dinou/core/client.jsx +17 -3
- package/dinou/core/link.jsx +5 -4
- package/dinou/core/navigation-utils.js +4 -2
- package/dinou/core/render-app-to-html.js +29 -7
- package/dinou/core/server-function-proxy-webpack.js +66 -63
- package/dinou/core/server-function-proxy.js +66 -63
- package/dinou/core/server.js +65 -29
- package/dinou/core/url-resolver.js +13 -0
- package/dinou/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [4.0.13] - 2026-06-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Universal Redirects**: Full support for redirects on Server Components, `getProps`, Server Actions, and client SPA navigation.
|
|
12
|
+
- **Standard Relative Paths**: Support for relative paths (`../sibling`, `./child`, `?query`, `#hash`) matching directory-first URL resolution.
|
|
13
|
+
- **Absolute Link Hover**: Links now render resolved absolute paths to ensure correct browser previews and right-click behaviors.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **RSC Stream Stability**: Resolved socket crashes (`Connection closed`) and duplicate Express header warnings when redirects occur during streamed RSC requests.
|
|
17
|
+
- **SSR Process Leaks**: Render processes are now cleaned up immediately after an HTML redirect is triggered.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- **Redirect Sanitization**: Blocked unsafe external domains (`https://...`), protocol-relative URLs (`//...`), and scripting payloads (`javascript:...`).
|
|
21
|
+
|
|
22
|
+
### Refactored
|
|
23
|
+
- **Shared Helpers**: Unified path resolution logic into a shared module (`url-resolver.js`).
|
|
24
|
+
|
|
25
|
+
## [4.0.12] - 2026-06-28
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
- **Fix DOM XSS / Script Injection Vulnerability:** Replaced inline HTML/Script injection (`<script>` tags executed via `createContextualFragment` in client-side proxies) with a secure, data-driven line-based protocol for active streams and custom HTTP headers/JSON payload signaling for redirects.
|
|
29
|
+
- **CSP (Content Security Policy) Compatibility:** Removed all `'unsafe-inline'` script injections, ensuring full compatibility with strict security policies.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Stream Redirection & Cookie Handling:** Improved robustness and predictability of stream reading in server-function proxies by switching to a line-by-line (`\n` delimited) streaming command parser, eliminating regex-based tag extraction.
|
|
33
|
+
|
|
8
34
|
## [4.0.11]
|
|
9
35
|
|
|
10
36
|
### Fixed
|
|
@@ -38,7 +38,7 @@ const isHashChangeOnly = (finalPath) => {
|
|
|
38
38
|
);
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
const getRSCPayload = (rscKey) => {
|
|
41
|
+
const getRSCPayload = (rscKey, isPrefetch = false) => {
|
|
42
42
|
const url = rscKey.split("::")[0];
|
|
43
43
|
// 1. Check Idempotence (Avoids the infinite loop of React)
|
|
44
44
|
if (cache.has(url)) {
|
|
@@ -64,7 +64,21 @@ const getRSCPayload = (rscKey) => {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// 3. Fetch and Cache Storage
|
|
67
|
-
const promise = createFromFetch(
|
|
67
|
+
const promise = createFromFetch(
|
|
68
|
+
fetch(payloadUrl).then((res) => {
|
|
69
|
+
if (res.headers.has("x-rsc-redirect")) {
|
|
70
|
+
const redirectUrl = res.headers.get("x-rsc-redirect");
|
|
71
|
+
if (!isPrefetch) {
|
|
72
|
+
window.location.href = redirectUrl;
|
|
73
|
+
} else {
|
|
74
|
+
cache.delete(url);
|
|
75
|
+
}
|
|
76
|
+
// Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
|
|
77
|
+
return new Promise(() => {});
|
|
78
|
+
}
|
|
79
|
+
return res;
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
68
82
|
cache.set(url, promise); // <--- KEY TO AVOID LOOP
|
|
69
83
|
return promise;
|
|
70
84
|
};
|
|
@@ -84,7 +98,7 @@ function Router() {
|
|
|
84
98
|
window.__DINOU_PREFETCH__ = (url) => {
|
|
85
99
|
// 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
|
|
86
100
|
if (isHashChangeOnly(url)) return;
|
|
87
|
-
getRSCPayload(url);
|
|
101
|
+
getRSCPayload(url, true);
|
|
88
102
|
};
|
|
89
103
|
|
|
90
104
|
// Hydration
|
package/dinou/core/client.jsx
CHANGED
|
@@ -39,7 +39,7 @@ const isHashChangeOnly = (finalPath) => {
|
|
|
39
39
|
);
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const getRSCPayload = (rscKey) => {
|
|
42
|
+
const getRSCPayload = (rscKey, isPrefetch = false) => {
|
|
43
43
|
const url = rscKey.split("::")[0];
|
|
44
44
|
// 1. Check Idempotence (Avoids the infinite loop of React)
|
|
45
45
|
if (cache.has(url)) {
|
|
@@ -65,7 +65,21 @@ const getRSCPayload = (rscKey) => {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// 3. Fetch and Cache Storage
|
|
68
|
-
const promise = createFromFetch(
|
|
68
|
+
const promise = createFromFetch(
|
|
69
|
+
fetch(payloadUrl).then((res) => {
|
|
70
|
+
if (res.headers.has("x-rsc-redirect")) {
|
|
71
|
+
const redirectUrl = res.headers.get("x-rsc-redirect");
|
|
72
|
+
if (!isPrefetch) {
|
|
73
|
+
window.location.href = redirectUrl;
|
|
74
|
+
} else {
|
|
75
|
+
cache.delete(url);
|
|
76
|
+
}
|
|
77
|
+
// Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
|
|
78
|
+
return new Promise(() => {});
|
|
79
|
+
}
|
|
80
|
+
return res;
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
69
83
|
cache.set(url, promise); // <--- KEY TO AVOID LOOP
|
|
70
84
|
return promise;
|
|
71
85
|
};
|
|
@@ -85,7 +99,7 @@ function Router() {
|
|
|
85
99
|
window.__DINOU_PREFETCH__ = (url) => {
|
|
86
100
|
// 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
|
|
87
101
|
if (isHashChangeOnly(url)) return;
|
|
88
|
-
getRSCPayload(url);
|
|
102
|
+
getRSCPayload(url, true);
|
|
89
103
|
};
|
|
90
104
|
|
|
91
105
|
// Hydration
|
package/dinou/core/link.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useRouter } from "./navigation.js";
|
|
3
|
+
import { useRouter, usePathname } from "./navigation.js";
|
|
4
4
|
import { resolveUrl } from "./navigation-utils.js";
|
|
5
5
|
|
|
6
6
|
export function Link({
|
|
@@ -11,12 +11,13 @@ export function Link({
|
|
|
11
11
|
...props
|
|
12
12
|
}) {
|
|
13
13
|
const { push } = useRouter();
|
|
14
|
+
const pathname = usePathname();
|
|
15
|
+
const resolvedHref = resolveUrl(href, pathname);
|
|
14
16
|
|
|
15
17
|
const handlePrefetch = () => {
|
|
16
18
|
if (!prefetch || !href || fresh) return;
|
|
17
|
-
const finalPath = resolveUrl(href, window.location.pathname);
|
|
18
19
|
if (window.__DINOU_PREFETCH__) {
|
|
19
|
-
window.__DINOU_PREFETCH__(
|
|
20
|
+
window.__DINOU_PREFETCH__(resolvedHref);
|
|
20
21
|
}
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -29,7 +30,7 @@ export function Link({
|
|
|
29
30
|
|
|
30
31
|
return (
|
|
31
32
|
<a
|
|
32
|
-
href={
|
|
33
|
+
href={resolvedHref}
|
|
33
34
|
onClick={handleClick}
|
|
34
35
|
onMouseEnter={handlePrefetch}
|
|
35
36
|
{...props}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
export function resolveUrl(href, currentPathname) {
|
|
2
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
|
3
|
+
|
|
2
4
|
if (href.startsWith("/") || href.includes("://")) {
|
|
3
|
-
const url = new URL(href,
|
|
5
|
+
const url = new URL(href, origin);
|
|
4
6
|
return normalize(url.pathname + url.search + url.hash);
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
let base = currentPathname;
|
|
8
10
|
if (!base.endsWith("/")) base += "/";
|
|
9
11
|
|
|
10
|
-
const resolved = new URL(href,
|
|
12
|
+
const resolved = new URL(href, origin + base);
|
|
11
13
|
return normalize(resolved.pathname + resolved.search + resolved.hash);
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -27,6 +27,8 @@ const childExecArgv = ESSENTIAL_NODE_ARGS.concat(loaderArg);
|
|
|
27
27
|
|
|
28
28
|
// ----------------------------------------------------
|
|
29
29
|
|
|
30
|
+
const { resolveRelativeUrl } = require("./url-resolver");
|
|
31
|
+
|
|
30
32
|
function renderAppToHtml(
|
|
31
33
|
reqPath,
|
|
32
34
|
paramsString,
|
|
@@ -82,13 +84,28 @@ function renderAppToHtml(
|
|
|
82
84
|
if (res.headersSent) {
|
|
83
85
|
// --- REDIRECT (JS Injection) ---
|
|
84
86
|
if (command === "redirect") {
|
|
85
|
-
const
|
|
87
|
+
const rawUrl = args.length === 1 ? args[0] : args[1];
|
|
88
|
+
const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath);
|
|
86
89
|
console.log(
|
|
87
|
-
`[Dinou] Streaming active. Redirecting via JavaScript to: ${
|
|
90
|
+
`[Dinou] Streaming active. Redirecting via JavaScript to: ${resolvedUrl}`,
|
|
88
91
|
);
|
|
89
|
-
|
|
92
|
+
let finalUrl = "/";
|
|
93
|
+
if (
|
|
94
|
+
typeof resolvedUrl === "string" &&
|
|
95
|
+
resolvedUrl.startsWith("/") &&
|
|
96
|
+
!resolvedUrl.startsWith("//")
|
|
97
|
+
) {
|
|
98
|
+
finalUrl = resolvedUrl;
|
|
99
|
+
} else {
|
|
100
|
+
console.warn(
|
|
101
|
+
`[Dinou Security] Blocked unsafe streaming redirect to: ${rawUrl}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const safeUrl = JSON.stringify(finalUrl);
|
|
90
105
|
res.write(`<script>window.location.href = ${safeUrl};</script>`);
|
|
91
106
|
res.end();
|
|
107
|
+
child.stdout.unpipe(res);
|
|
108
|
+
child.kill();
|
|
92
109
|
return;
|
|
93
110
|
}
|
|
94
111
|
|
|
@@ -186,16 +203,19 @@ function renderAppToHtml(
|
|
|
186
203
|
rawUrl = args[0];
|
|
187
204
|
}
|
|
188
205
|
|
|
206
|
+
// Resolve relative URL relative to reqPath
|
|
207
|
+
const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath);
|
|
208
|
+
|
|
189
209
|
// 2. Security Logic (Safe Redirect)
|
|
190
210
|
// We allow only relative routes starting with '/' but not '//'
|
|
191
211
|
let finalUrl = "/";
|
|
192
212
|
|
|
193
213
|
if (
|
|
194
|
-
typeof
|
|
195
|
-
|
|
196
|
-
!
|
|
214
|
+
typeof resolvedUrl === "string" &&
|
|
215
|
+
resolvedUrl.startsWith("/") &&
|
|
216
|
+
!resolvedUrl.startsWith("//")
|
|
197
217
|
) {
|
|
198
|
-
finalUrl =
|
|
218
|
+
finalUrl = resolvedUrl;
|
|
199
219
|
} else {
|
|
200
220
|
console.warn(
|
|
201
221
|
`[Dinou Security] Blocked unsafe redirect to: ${rawUrl}`,
|
|
@@ -205,6 +225,8 @@ function renderAppToHtml(
|
|
|
205
225
|
|
|
206
226
|
// 3. Execute safe redirect always using (status, url)
|
|
207
227
|
res.redirect.apply(res, [status, finalUrl]);
|
|
228
|
+
child.stdout.unpipe(res);
|
|
229
|
+
child.kill();
|
|
208
230
|
return;
|
|
209
231
|
}
|
|
210
232
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createFromFetch } from "react-server-dom-webpack/client";
|
|
2
2
|
|
|
3
|
+
function isSafeRedirect(url) {
|
|
4
|
+
return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
function createServerFunctionProxy(id) {
|
|
4
8
|
return new Proxy(() => { }, {
|
|
5
9
|
apply: async (_target, _thisArg, args) => {
|
|
@@ -30,15 +34,30 @@ function createServerFunctionProxy(id) {
|
|
|
30
34
|
|
|
31
35
|
if (!res.ok) throw new Error("Server function failed");
|
|
32
36
|
|
|
37
|
+
// Check header first
|
|
38
|
+
const redirectUrl = res.headers.get("X-Dinou-Redirect");
|
|
39
|
+
if (redirectUrl) {
|
|
40
|
+
window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
|
|
41
|
+
return new Promise(() => { });
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
const contentType = res.headers.get("content-type") || "";
|
|
34
45
|
|
|
35
|
-
//
|
|
46
|
+
// Reject unexpected HTML responses entirely to prevent security bugs
|
|
36
47
|
if (contentType.includes("text/html")) {
|
|
37
48
|
const html = await res.text();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
console.warn("[Dinou] Received unexpected HTML response from server function:", html);
|
|
50
|
+
throw new Error("Unexpected HTML response from server function");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if it's application/json (Scenario A clean redirect or json response)
|
|
54
|
+
if (contentType.includes("application/json")) {
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (data && data.redirect) {
|
|
57
|
+
window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
|
|
58
|
+
return new Promise(() => { });
|
|
59
|
+
}
|
|
60
|
+
return data;
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
// Case 2: RSC or Hybrid Stream (Buffered)
|
|
@@ -46,7 +65,6 @@ function createServerFunctionProxy(id) {
|
|
|
46
65
|
const reader = res.body.getReader();
|
|
47
66
|
const decoder = new TextDecoder();
|
|
48
67
|
const encoder = new TextEncoder();
|
|
49
|
-
const scriptRegex = /<script>(.*?)<\/script>/gs;
|
|
50
68
|
|
|
51
69
|
const readableStream = new ReadableStream({
|
|
52
70
|
async start(controller) {
|
|
@@ -58,16 +76,20 @@ function createServerFunctionProxy(id) {
|
|
|
58
76
|
const { done, value } = await reader.read();
|
|
59
77
|
|
|
60
78
|
if (done) {
|
|
61
|
-
// If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
|
|
62
79
|
if (buffer.length > 0) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
if (buffer.startsWith("D:")) {
|
|
81
|
+
try {
|
|
82
|
+
const payload = JSON.parse(buffer.slice(2));
|
|
83
|
+
if (payload.type === "redirect") {
|
|
84
|
+
isRedirecting = true;
|
|
85
|
+
window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
|
|
86
|
+
} else if (payload.type === "cookie") {
|
|
87
|
+
document.cookie = payload.cookie;
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error("[Dinou] Failed to parse stream command line at end:", e);
|
|
91
|
+
}
|
|
69
92
|
} else {
|
|
70
|
-
// It is safe content (e.g., "a < b" or a cut JSON that React will handle)
|
|
71
93
|
controller.enqueue(encoder.encode(buffer));
|
|
72
94
|
}
|
|
73
95
|
}
|
|
@@ -77,65 +99,46 @@ function createServerFunctionProxy(id) {
|
|
|
77
99
|
// 1. ACCUMULATE
|
|
78
100
|
buffer += decoder.decode(value, { stream: true });
|
|
79
101
|
|
|
80
|
-
// 2. PROCESS COMPLETE
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
// 2. PROCESS COMPLETE LINES
|
|
103
|
+
const lastNewlineIndex = buffer.lastIndexOf("\n");
|
|
104
|
+
if (lastNewlineIndex !== -1) {
|
|
105
|
+
const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
|
|
106
|
+
buffer = buffer.slice(lastNewlineIndex + 1);
|
|
83
107
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const fullMatch = match[0];
|
|
87
|
-
const scriptContent = match[1];
|
|
108
|
+
const lines = completeChunk.split("\n");
|
|
109
|
+
let cleanChunk = "";
|
|
88
110
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Inject to DOM
|
|
95
|
-
const range = document.createRange();
|
|
96
|
-
const fragment = range.createContextualFragment(fullMatch); // fullMatch includes tags for correct context
|
|
97
|
-
document.body.appendChild(fragment);
|
|
98
|
-
}
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
if (!line && i === lines.length - 1) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
} else {
|
|
116
|
-
// B) If no open script, check if the end looks like a cut tag
|
|
117
|
-
// Regex: Looks for '<' optionally followed by s, c, r, i, p, t AT THE END of the string ($)
|
|
118
|
-
const partialTagMatch = buffer.match(/<s?c?r?i?p?t?$/);
|
|
119
|
-
|
|
120
|
-
if (partialTagMatch) {
|
|
121
|
-
// We save from where the suspicion starts
|
|
122
|
-
cutoffIndex = partialTagMatch.index;
|
|
117
|
+
if (line.startsWith("D:")) {
|
|
118
|
+
try {
|
|
119
|
+
const payload = JSON.parse(line.slice(2));
|
|
120
|
+
if (payload.type === "redirect") {
|
|
121
|
+
isRedirecting = true;
|
|
122
|
+
window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
|
|
123
|
+
} else if (payload.type === "cookie") {
|
|
124
|
+
document.cookie = payload.cookie;
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error("[Dinou] Failed to parse stream command line:", e);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
cleanChunk += line + "\n";
|
|
131
|
+
}
|
|
123
132
|
}
|
|
124
|
-
}
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
buffer = buffer.slice(cutoffIndex);
|
|
130
|
-
|
|
131
|
-
if (safeChunk) {
|
|
132
|
-
controller.enqueue(encoder.encode(safeChunk));
|
|
134
|
+
if (cleanChunk) {
|
|
135
|
+
controller.enqueue(encoder.encode(cleanChunk));
|
|
136
|
+
}
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
} catch (err) {
|
|
136
140
|
controller.error(err);
|
|
137
141
|
} finally {
|
|
138
|
-
// If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
|
|
139
142
|
if (!isRedirecting) {
|
|
140
143
|
controller.close();
|
|
141
144
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// public/server-function-proxy.js
|
|
2
2
|
import { createFromFetch } from "@roggc/react-server-dom-esm/client";
|
|
3
3
|
|
|
4
|
+
function isSafeRedirect(url) {
|
|
5
|
+
return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
|
|
6
|
+
}
|
|
7
|
+
|
|
4
8
|
export function createServerFunctionProxy(id) {
|
|
5
9
|
return new Proxy(() => {}, {
|
|
6
10
|
apply: async (_target, _thisArg, args) => {
|
|
@@ -31,15 +35,30 @@ export function createServerFunctionProxy(id) {
|
|
|
31
35
|
|
|
32
36
|
if (!res.ok) throw new Error("Server function failed");
|
|
33
37
|
|
|
38
|
+
// Check header first
|
|
39
|
+
const redirectUrl = res.headers.get("X-Dinou-Redirect");
|
|
40
|
+
if (redirectUrl) {
|
|
41
|
+
window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
|
|
42
|
+
return new Promise(() => {});
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
const contentType = res.headers.get("content-type") || "";
|
|
35
46
|
|
|
36
|
-
//
|
|
47
|
+
// Reject unexpected HTML responses entirely to prevent security bugs
|
|
37
48
|
if (contentType.includes("text/html")) {
|
|
38
49
|
const html = await res.text();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
console.warn("[Dinou] Received unexpected HTML response from server function:", html);
|
|
51
|
+
throw new Error("Unexpected HTML response from server function");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if it's application/json (Scenario A clean redirect or json response)
|
|
55
|
+
if (contentType.includes("application/json")) {
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
if (data && data.redirect) {
|
|
58
|
+
window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
|
|
59
|
+
return new Promise(() => {});
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
// Case 2: RSC or Hybrid Stream (Buffered)
|
|
@@ -47,7 +66,6 @@ export function createServerFunctionProxy(id) {
|
|
|
47
66
|
const reader = res.body.getReader();
|
|
48
67
|
const decoder = new TextDecoder();
|
|
49
68
|
const encoder = new TextEncoder();
|
|
50
|
-
const scriptRegex = /<script>(.*?)<\/script>/gs;
|
|
51
69
|
|
|
52
70
|
const readableStream = new ReadableStream({
|
|
53
71
|
async start(controller) {
|
|
@@ -59,16 +77,20 @@ export function createServerFunctionProxy(id) {
|
|
|
59
77
|
const { done, value } = await reader.read();
|
|
60
78
|
|
|
61
79
|
if (done) {
|
|
62
|
-
// If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
|
|
63
80
|
if (buffer.length > 0) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
if (buffer.startsWith("D:")) {
|
|
82
|
+
try {
|
|
83
|
+
const payload = JSON.parse(buffer.slice(2));
|
|
84
|
+
if (payload.type === "redirect") {
|
|
85
|
+
isRedirecting = true;
|
|
86
|
+
window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
|
|
87
|
+
} else if (payload.type === "cookie") {
|
|
88
|
+
document.cookie = payload.cookie;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error("[Dinou] Failed to parse stream command line at end:", e);
|
|
92
|
+
}
|
|
70
93
|
} else {
|
|
71
|
-
// It is safe content (e.g., "a < b" or a cut JSON that React will handle)
|
|
72
94
|
controller.enqueue(encoder.encode(buffer));
|
|
73
95
|
}
|
|
74
96
|
}
|
|
@@ -78,65 +100,46 @@ export function createServerFunctionProxy(id) {
|
|
|
78
100
|
// 1. ACCUMULATE
|
|
79
101
|
buffer += decoder.decode(value, { stream: true });
|
|
80
102
|
|
|
81
|
-
// 2. PROCESS COMPLETE
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
// 2. PROCESS COMPLETE LINES
|
|
104
|
+
const lastNewlineIndex = buffer.lastIndexOf("\n");
|
|
105
|
+
if (lastNewlineIndex !== -1) {
|
|
106
|
+
const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
|
|
107
|
+
buffer = buffer.slice(lastNewlineIndex + 1);
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const fullMatch = match[0];
|
|
88
|
-
const scriptContent = match[1];
|
|
109
|
+
const lines = completeChunk.split("\n");
|
|
110
|
+
let cleanChunk = "";
|
|
89
111
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// Inject to DOM
|
|
96
|
-
const range = document.createRange();
|
|
97
|
-
const fragment = range.createContextualFragment(fullMatch); // fullMatch includes tags for correct context
|
|
98
|
-
document.body.appendChild(fragment);
|
|
99
|
-
}
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
if (!line && i === lines.length - 1) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
100
117
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} else {
|
|
117
|
-
// B) If no open script, check if the end looks like a cut tag
|
|
118
|
-
// Regex: Looks for '<' optionally followed by s, c, r, i, p, t AT THE END of the string ($)
|
|
119
|
-
const partialTagMatch = buffer.match(/<s?c?r?i?p?t?$/);
|
|
120
|
-
|
|
121
|
-
if (partialTagMatch) {
|
|
122
|
-
// We save from where the suspicion starts
|
|
123
|
-
cutoffIndex = partialTagMatch.index;
|
|
118
|
+
if (line.startsWith("D:")) {
|
|
119
|
+
try {
|
|
120
|
+
const payload = JSON.parse(line.slice(2));
|
|
121
|
+
if (payload.type === "redirect") {
|
|
122
|
+
isRedirecting = true;
|
|
123
|
+
window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
|
|
124
|
+
} else if (payload.type === "cookie") {
|
|
125
|
+
document.cookie = payload.cookie;
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("[Dinou] Failed to parse stream command line:", e);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
cleanChunk += line + "\n";
|
|
132
|
+
}
|
|
124
133
|
}
|
|
125
|
-
}
|
|
126
134
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
buffer = buffer.slice(cutoffIndex);
|
|
131
|
-
|
|
132
|
-
if (safeChunk) {
|
|
133
|
-
controller.enqueue(encoder.encode(safeChunk));
|
|
135
|
+
if (cleanChunk) {
|
|
136
|
+
controller.enqueue(encoder.encode(cleanChunk));
|
|
137
|
+
}
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
} catch (err) {
|
|
137
141
|
controller.error(err);
|
|
138
142
|
} finally {
|
|
139
|
-
// If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
|
|
140
143
|
if (!isRedirecting) {
|
|
141
144
|
controller.close();
|
|
142
145
|
}
|
package/dinou/core/server.js
CHANGED
|
@@ -248,20 +248,24 @@ const appUseCookieParser = cookieParser();
|
|
|
248
248
|
const app = express();
|
|
249
249
|
app.use(appUseCookieParser);
|
|
250
250
|
app.use(express.json());
|
|
251
|
+
const { resolveRelativeUrl } = require("./url-resolver");
|
|
251
252
|
|
|
252
253
|
function getContext(req, res) {
|
|
254
|
+
let hasRedirected = false;
|
|
253
255
|
// Helper to execute res methods safely
|
|
254
256
|
const safeResCall = (methodName, ...args) => {
|
|
257
|
+
if (hasRedirected) return;
|
|
255
258
|
if (res.headersSent) {
|
|
259
|
+
if (methodName === "redirect" && req.path.includes("____rsc_payload")) {
|
|
260
|
+
return; // Silence streaming redirects during RSC payload requests
|
|
261
|
+
}
|
|
256
262
|
console.log(
|
|
257
263
|
`[Dinou] res.${methodName} called but headers already sent. Ignoring.`,
|
|
258
264
|
);
|
|
259
|
-
// console.warn(
|
|
260
|
-
// `[Dinou Warning] RSC Stream active. Ignoring res.${methodName}() to avoid crash.`
|
|
261
|
-
// );
|
|
262
265
|
return; // Exit silently
|
|
263
266
|
}
|
|
264
267
|
if (methodName === "redirect") {
|
|
268
|
+
hasRedirected = true;
|
|
265
269
|
// 1. Normalize arguments (Status and URL)
|
|
266
270
|
let url = args[0];
|
|
267
271
|
let status = 302; // Default
|
|
@@ -272,16 +276,27 @@ function getContext(req, res) {
|
|
|
272
276
|
}
|
|
273
277
|
|
|
274
278
|
function safeRedirect(targetUrl) {
|
|
275
|
-
|
|
279
|
+
const resolvedUrl = resolveRelativeUrl(targetUrl, req.path);
|
|
280
|
+
let finalUrl = "/";
|
|
276
281
|
if (
|
|
277
|
-
typeof
|
|
278
|
-
|
|
279
|
-
!
|
|
282
|
+
typeof resolvedUrl === "string" &&
|
|
283
|
+
resolvedUrl.startsWith("/") &&
|
|
284
|
+
!resolvedUrl.startsWith("//")
|
|
280
285
|
) {
|
|
281
|
-
|
|
286
|
+
finalUrl = resolvedUrl;
|
|
282
287
|
} else {
|
|
283
|
-
|
|
288
|
+
console.warn(
|
|
289
|
+
`[Dinou Security] Blocked unsafe redirect to: ${targetUrl}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (req.path.includes("____rsc_payload")) {
|
|
294
|
+
res.setHeader("x-rsc-redirect", finalUrl);
|
|
295
|
+
res.status(200).end();
|
|
296
|
+
return;
|
|
284
297
|
}
|
|
298
|
+
|
|
299
|
+
res.redirect.apply(res, [status, finalUrl]);
|
|
285
300
|
}
|
|
286
301
|
return safeRedirect(url);
|
|
287
302
|
}
|
|
@@ -351,11 +366,31 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
351
366
|
},
|
|
352
367
|
res: {
|
|
353
368
|
redirect: (urlOrStatus, url) => {
|
|
354
|
-
const
|
|
369
|
+
const rawUrl = url || urlOrStatus;
|
|
370
|
+
const referer = req.headers["referer"];
|
|
371
|
+
let refererPath = "/";
|
|
372
|
+
if (referer) {
|
|
373
|
+
try {
|
|
374
|
+
refererPath = new URL(referer).pathname;
|
|
375
|
+
} catch (e) {}
|
|
376
|
+
}
|
|
377
|
+
const resolvedUrl = resolveRelativeUrl(rawUrl, refererPath);
|
|
378
|
+
let finalUrl = "/";
|
|
379
|
+
if (
|
|
380
|
+
typeof resolvedUrl === "string" &&
|
|
381
|
+
resolvedUrl.startsWith("/") &&
|
|
382
|
+
!resolvedUrl.startsWith("//")
|
|
383
|
+
) {
|
|
384
|
+
finalUrl = resolvedUrl;
|
|
385
|
+
} else {
|
|
386
|
+
console.warn(
|
|
387
|
+
`[Dinou Security] Blocked unsafe server function redirect to: ${rawUrl}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
355
390
|
// We throw a special object that the endpoint will intercept
|
|
356
391
|
throw {
|
|
357
392
|
$$type: "dinou-internal-redirect",
|
|
358
|
-
url:
|
|
393
|
+
url: finalUrl,
|
|
359
394
|
};
|
|
360
395
|
},
|
|
361
396
|
status: (code) => {
|
|
@@ -380,7 +415,7 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
380
415
|
}
|
|
381
416
|
|
|
382
417
|
// SCENARIO B: Streaming active (Headers Sent)
|
|
383
|
-
//
|
|
418
|
+
// Send safe stream command.
|
|
384
419
|
|
|
385
420
|
// 🛑 Security: JS cannot write HttpOnly cookies
|
|
386
421
|
if (options && options.httpOnly) {
|
|
@@ -404,11 +439,11 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
404
439
|
if (options.sameSite) cookieStr += `; samesite=${options.sameSite}`;
|
|
405
440
|
}
|
|
406
441
|
|
|
407
|
-
// Safe packaging for
|
|
442
|
+
// Safe packaging for stream command
|
|
408
443
|
const safeCookieStr = JSON.stringify(cookieStr);
|
|
409
444
|
|
|
410
445
|
// Write to stream
|
|
411
|
-
res.write(
|
|
446
|
+
res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
|
|
412
447
|
},
|
|
413
448
|
|
|
414
449
|
clearCookie: (name, options) => {
|
|
@@ -421,13 +456,10 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
421
456
|
return;
|
|
422
457
|
}
|
|
423
458
|
|
|
424
|
-
// SCENARIO B:
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
res.write(
|
|
429
|
-
`<script>document.cookie = ${safeName} + "=; Max-Age=0; path=" + ${safePath} + ";";</script>`,
|
|
430
|
-
);
|
|
459
|
+
// SCENARIO B: Custom stream command
|
|
460
|
+
const cookieStr = `${name}=; Max-Age=0; path=${path};`;
|
|
461
|
+
const safeCookieStr = JSON.stringify(cookieStr);
|
|
462
|
+
res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
|
|
431
463
|
},
|
|
432
464
|
},
|
|
433
465
|
};
|
|
@@ -533,6 +565,11 @@ async function serveRSCPayload(req, res, isOld = false, isStatic = false) {
|
|
|
533
565
|
isNotFound,
|
|
534
566
|
isDevelopment,
|
|
535
567
|
);
|
|
568
|
+
|
|
569
|
+
if (res.headersSent) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
536
573
|
const manifest = isDevelopment
|
|
537
574
|
? JSON.parse(
|
|
538
575
|
readFileSync(
|
|
@@ -935,18 +972,17 @@ app.post("/____server_function____", async (req, res) => {
|
|
|
935
972
|
} catch (err) {
|
|
936
973
|
// 💡 WE INTERCEPT THE REDIRECT
|
|
937
974
|
if (err && err.$$type === "dinou-internal-redirect") {
|
|
938
|
-
// 1. Always sanitize the URL
|
|
939
975
|
const safeUrl = JSON.stringify(err.url);
|
|
940
|
-
const script = `<script>window.location.href = ${safeUrl};</script>`;
|
|
941
976
|
|
|
942
977
|
if (!res.headersSent) {
|
|
943
|
-
// SCENARIO A: Clean (Content-Type
|
|
944
|
-
res.setHeader("Content-Type", "
|
|
945
|
-
|
|
978
|
+
// SCENARIO A: Clean (Content-Type application/json)
|
|
979
|
+
res.setHeader("Content-Type", "application/json");
|
|
980
|
+
res.setHeader("X-Dinou-Redirect", err.url);
|
|
981
|
+
return res.status(200).json({ redirect: err.url });
|
|
946
982
|
} else {
|
|
947
|
-
// SCENARIO B: Dirty/Active Stream
|
|
948
|
-
// Write
|
|
949
|
-
res.write(
|
|
983
|
+
// SCENARIO B: Dirty/Active Stream
|
|
984
|
+
// Write a custom line-based stream command
|
|
985
|
+
res.write(`D:{"type":"redirect","url":${safeUrl}}\n`);
|
|
950
986
|
|
|
951
987
|
// ⚠️ IMPORTANT:
|
|
952
988
|
// 1. We close the response, since we redirected and there will be no RSC payload.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function resolveRelativeUrl(href, currentPathname) {
|
|
2
|
+
if (href.startsWith("/") || href.includes("://")) {
|
|
3
|
+
return href;
|
|
4
|
+
}
|
|
5
|
+
let base = currentPathname || "/";
|
|
6
|
+
if (!base.endsWith("/")) {
|
|
7
|
+
base += "/";
|
|
8
|
+
}
|
|
9
|
+
const resolved = new URL(href, "http://localhost" + base);
|
|
10
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { resolveRelativeUrl };
|
package/dinou/package.json
CHANGED