dinou 4.0.11 → 4.0.12
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 +9 -0
- package/dinou/core/server-function-proxy-webpack.js +62 -63
- package/dinou/core/server-function-proxy.js +62 -63
- package/dinou/core/server.js +14 -18
- package/dinou/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ 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.12] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **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.
|
|
12
|
+
- **CSP (Content Security Policy) Compatibility:** Removed all `'unsafe-inline'` script injections, ensuring full compatibility with strict security policies.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
8
17
|
## [4.0.11]
|
|
9
18
|
|
|
10
19
|
### Fixed
|
|
@@ -30,15 +30,30 @@ function createServerFunctionProxy(id) {
|
|
|
30
30
|
|
|
31
31
|
if (!res.ok) throw new Error("Server function failed");
|
|
32
32
|
|
|
33
|
+
// Check header first
|
|
34
|
+
const redirectUrl = res.headers.get("X-Dinou-Redirect");
|
|
35
|
+
if (redirectUrl) {
|
|
36
|
+
window.location.href = redirectUrl;
|
|
37
|
+
return new Promise(() => { });
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
const contentType = res.headers.get("content-type") || "";
|
|
34
41
|
|
|
35
|
-
//
|
|
42
|
+
// Reject unexpected HTML responses entirely to prevent security bugs
|
|
36
43
|
if (contentType.includes("text/html")) {
|
|
37
44
|
const html = await res.text();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
console.warn("[Dinou] Received unexpected HTML response from server function:", html);
|
|
46
|
+
throw new Error("Unexpected HTML response from server function");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if it's application/json (Scenario A clean redirect or json response)
|
|
50
|
+
if (contentType.includes("application/json")) {
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
if (data && data.redirect) {
|
|
53
|
+
window.location.href = data.redirect;
|
|
54
|
+
return new Promise(() => { });
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
// Case 2: RSC or Hybrid Stream (Buffered)
|
|
@@ -46,7 +61,6 @@ function createServerFunctionProxy(id) {
|
|
|
46
61
|
const reader = res.body.getReader();
|
|
47
62
|
const decoder = new TextDecoder();
|
|
48
63
|
const encoder = new TextEncoder();
|
|
49
|
-
const scriptRegex = /<script>(.*?)<\/script>/gs;
|
|
50
64
|
|
|
51
65
|
const readableStream = new ReadableStream({
|
|
52
66
|
async start(controller) {
|
|
@@ -58,16 +72,20 @@ function createServerFunctionProxy(id) {
|
|
|
58
72
|
const { done, value } = await reader.read();
|
|
59
73
|
|
|
60
74
|
if (done) {
|
|
61
|
-
// If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
|
|
62
75
|
if (buffer.length > 0) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
if (buffer.startsWith("D:")) {
|
|
77
|
+
try {
|
|
78
|
+
const payload = JSON.parse(buffer.slice(2));
|
|
79
|
+
if (payload.type === "redirect") {
|
|
80
|
+
isRedirecting = true;
|
|
81
|
+
window.location.href = payload.url;
|
|
82
|
+
} else if (payload.type === "cookie") {
|
|
83
|
+
document.cookie = payload.cookie;
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error("[Dinou] Failed to parse stream command line at end:", e);
|
|
87
|
+
}
|
|
69
88
|
} else {
|
|
70
|
-
// It is safe content (e.g., "a < b" or a cut JSON that React will handle)
|
|
71
89
|
controller.enqueue(encoder.encode(buffer));
|
|
72
90
|
}
|
|
73
91
|
}
|
|
@@ -77,65 +95,46 @@ function createServerFunctionProxy(id) {
|
|
|
77
95
|
// 1. ACCUMULATE
|
|
78
96
|
buffer += decoder.decode(value, { stream: true });
|
|
79
97
|
|
|
80
|
-
// 2. PROCESS COMPLETE
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
while ((match = scriptRegex.exec(buffer)) !== null) {
|
|
86
|
-
const fullMatch = match[0];
|
|
87
|
-
const scriptContent = match[1];
|
|
98
|
+
// 2. PROCESS COMPLETE LINES
|
|
99
|
+
const lastNewlineIndex = buffer.lastIndexOf("\n");
|
|
100
|
+
if (lastNewlineIndex !== -1) {
|
|
101
|
+
const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
|
|
102
|
+
buffer = buffer.slice(lastNewlineIndex + 1);
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
isRedirecting = true;
|
|
92
|
-
}
|
|
104
|
+
const lines = completeChunk.split("\n");
|
|
105
|
+
let cleanChunk = "";
|
|
93
106
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
const line = lines[i];
|
|
109
|
+
if (!line && i === lines.length - 1) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
99
112
|
|
|
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;
|
|
113
|
+
if (line.startsWith("D:")) {
|
|
114
|
+
try {
|
|
115
|
+
const payload = JSON.parse(line.slice(2));
|
|
116
|
+
if (payload.type === "redirect") {
|
|
117
|
+
isRedirecting = true;
|
|
118
|
+
window.location.href = payload.url;
|
|
119
|
+
} else if (payload.type === "cookie") {
|
|
120
|
+
document.cookie = payload.cookie;
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error("[Dinou] Failed to parse stream command line:", e);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
cleanChunk += line + "\n";
|
|
127
|
+
}
|
|
123
128
|
}
|
|
124
|
-
}
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
buffer = buffer.slice(cutoffIndex);
|
|
130
|
-
|
|
131
|
-
if (safeChunk) {
|
|
132
|
-
controller.enqueue(encoder.encode(safeChunk));
|
|
130
|
+
if (cleanChunk) {
|
|
131
|
+
controller.enqueue(encoder.encode(cleanChunk));
|
|
132
|
+
}
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
} catch (err) {
|
|
136
136
|
controller.error(err);
|
|
137
137
|
} finally {
|
|
138
|
-
// If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
|
|
139
138
|
if (!isRedirecting) {
|
|
140
139
|
controller.close();
|
|
141
140
|
}
|
|
@@ -31,15 +31,30 @@ export function createServerFunctionProxy(id) {
|
|
|
31
31
|
|
|
32
32
|
if (!res.ok) throw new Error("Server function failed");
|
|
33
33
|
|
|
34
|
+
// Check header first
|
|
35
|
+
const redirectUrl = res.headers.get("X-Dinou-Redirect");
|
|
36
|
+
if (redirectUrl) {
|
|
37
|
+
window.location.href = redirectUrl;
|
|
38
|
+
return new Promise(() => {});
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
const contentType = res.headers.get("content-type") || "";
|
|
35
42
|
|
|
36
|
-
//
|
|
43
|
+
// Reject unexpected HTML responses entirely to prevent security bugs
|
|
37
44
|
if (contentType.includes("text/html")) {
|
|
38
45
|
const html = await res.text();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
console.warn("[Dinou] Received unexpected HTML response from server function:", html);
|
|
47
|
+
throw new Error("Unexpected HTML response from server function");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if it's application/json (Scenario A clean redirect or json response)
|
|
51
|
+
if (contentType.includes("application/json")) {
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
if (data && data.redirect) {
|
|
54
|
+
window.location.href = data.redirect;
|
|
55
|
+
return new Promise(() => {});
|
|
56
|
+
}
|
|
57
|
+
return data;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
// Case 2: RSC or Hybrid Stream (Buffered)
|
|
@@ -47,7 +62,6 @@ export function createServerFunctionProxy(id) {
|
|
|
47
62
|
const reader = res.body.getReader();
|
|
48
63
|
const decoder = new TextDecoder();
|
|
49
64
|
const encoder = new TextEncoder();
|
|
50
|
-
const scriptRegex = /<script>(.*?)<\/script>/gs;
|
|
51
65
|
|
|
52
66
|
const readableStream = new ReadableStream({
|
|
53
67
|
async start(controller) {
|
|
@@ -59,16 +73,20 @@ export function createServerFunctionProxy(id) {
|
|
|
59
73
|
const { done, value } = await reader.read();
|
|
60
74
|
|
|
61
75
|
if (done) {
|
|
62
|
-
// If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
|
|
63
76
|
if (buffer.length > 0) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if (buffer.startsWith("D:")) {
|
|
78
|
+
try {
|
|
79
|
+
const payload = JSON.parse(buffer.slice(2));
|
|
80
|
+
if (payload.type === "redirect") {
|
|
81
|
+
isRedirecting = true;
|
|
82
|
+
window.location.href = payload.url;
|
|
83
|
+
} else if (payload.type === "cookie") {
|
|
84
|
+
document.cookie = payload.cookie;
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error("[Dinou] Failed to parse stream command line at end:", e);
|
|
88
|
+
}
|
|
70
89
|
} else {
|
|
71
|
-
// It is safe content (e.g., "a < b" or a cut JSON that React will handle)
|
|
72
90
|
controller.enqueue(encoder.encode(buffer));
|
|
73
91
|
}
|
|
74
92
|
}
|
|
@@ -78,65 +96,46 @@ export function createServerFunctionProxy(id) {
|
|
|
78
96
|
// 1. ACCUMULATE
|
|
79
97
|
buffer += decoder.decode(value, { stream: true });
|
|
80
98
|
|
|
81
|
-
// 2. PROCESS COMPLETE
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
while ((match = scriptRegex.exec(buffer)) !== null) {
|
|
87
|
-
const fullMatch = match[0];
|
|
88
|
-
const scriptContent = match[1];
|
|
99
|
+
// 2. PROCESS COMPLETE LINES
|
|
100
|
+
const lastNewlineIndex = buffer.lastIndexOf("\n");
|
|
101
|
+
if (lastNewlineIndex !== -1) {
|
|
102
|
+
const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
|
|
103
|
+
buffer = buffer.slice(lastNewlineIndex + 1);
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
isRedirecting = true;
|
|
93
|
-
}
|
|
105
|
+
const lines = completeChunk.split("\n");
|
|
106
|
+
let cleanChunk = "";
|
|
94
107
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const line = lines[i];
|
|
110
|
+
if (!line && i === lines.length - 1) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
100
113
|
|
|
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;
|
|
114
|
+
if (line.startsWith("D:")) {
|
|
115
|
+
try {
|
|
116
|
+
const payload = JSON.parse(line.slice(2));
|
|
117
|
+
if (payload.type === "redirect") {
|
|
118
|
+
isRedirecting = true;
|
|
119
|
+
window.location.href = payload.url;
|
|
120
|
+
} else if (payload.type === "cookie") {
|
|
121
|
+
document.cookie = payload.cookie;
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error("[Dinou] Failed to parse stream command line:", e);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
cleanChunk += line + "\n";
|
|
128
|
+
}
|
|
124
129
|
}
|
|
125
|
-
}
|
|
126
130
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
buffer = buffer.slice(cutoffIndex);
|
|
131
|
-
|
|
132
|
-
if (safeChunk) {
|
|
133
|
-
controller.enqueue(encoder.encode(safeChunk));
|
|
131
|
+
if (cleanChunk) {
|
|
132
|
+
controller.enqueue(encoder.encode(cleanChunk));
|
|
133
|
+
}
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
} catch (err) {
|
|
137
137
|
controller.error(err);
|
|
138
138
|
} finally {
|
|
139
|
-
// If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
|
|
140
139
|
if (!isRedirecting) {
|
|
141
140
|
controller.close();
|
|
142
141
|
}
|
package/dinou/core/server.js
CHANGED
|
@@ -380,7 +380,7 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
// SCENARIO B: Streaming active (Headers Sent)
|
|
383
|
-
//
|
|
383
|
+
// Send safe stream command.
|
|
384
384
|
|
|
385
385
|
// 🛑 Security: JS cannot write HttpOnly cookies
|
|
386
386
|
if (options && options.httpOnly) {
|
|
@@ -404,11 +404,11 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
404
404
|
if (options.sameSite) cookieStr += `; samesite=${options.sameSite}`;
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
-
// Safe packaging for
|
|
407
|
+
// Safe packaging for stream command
|
|
408
408
|
const safeCookieStr = JSON.stringify(cookieStr);
|
|
409
409
|
|
|
410
410
|
// Write to stream
|
|
411
|
-
res.write(
|
|
411
|
+
res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
|
|
412
412
|
},
|
|
413
413
|
|
|
414
414
|
clearCookie: (name, options) => {
|
|
@@ -421,13 +421,10 @@ function getContextForServerFunctionEndpoint(req, res) {
|
|
|
421
421
|
return;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
// SCENARIO B:
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
res.write(
|
|
429
|
-
`<script>document.cookie = ${safeName} + "=; Max-Age=0; path=" + ${safePath} + ";";</script>`,
|
|
430
|
-
);
|
|
424
|
+
// SCENARIO B: Custom stream command
|
|
425
|
+
const cookieStr = `${name}=; Max-Age=0; path=${path};`;
|
|
426
|
+
const safeCookieStr = JSON.stringify(cookieStr);
|
|
427
|
+
res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
|
|
431
428
|
},
|
|
432
429
|
},
|
|
433
430
|
};
|
|
@@ -935,18 +932,17 @@ app.post("/____server_function____", async (req, res) => {
|
|
|
935
932
|
} catch (err) {
|
|
936
933
|
// 💡 WE INTERCEPT THE REDIRECT
|
|
937
934
|
if (err && err.$$type === "dinou-internal-redirect") {
|
|
938
|
-
// 1. Always sanitize the URL
|
|
939
935
|
const safeUrl = JSON.stringify(err.url);
|
|
940
|
-
const script = `<script>window.location.href = ${safeUrl};</script>`;
|
|
941
936
|
|
|
942
937
|
if (!res.headersSent) {
|
|
943
|
-
// SCENARIO A: Clean (Content-Type
|
|
944
|
-
res.setHeader("Content-Type", "
|
|
945
|
-
|
|
938
|
+
// SCENARIO A: Clean (Content-Type application/json)
|
|
939
|
+
res.setHeader("Content-Type", "application/json");
|
|
940
|
+
res.setHeader("X-Dinou-Redirect", err.url);
|
|
941
|
+
return res.status(200).json({ redirect: err.url });
|
|
946
942
|
} else {
|
|
947
|
-
// SCENARIO B: Dirty/Active Stream
|
|
948
|
-
// Write
|
|
949
|
-
res.write(
|
|
943
|
+
// SCENARIO B: Dirty/Active Stream
|
|
944
|
+
// Write a custom line-based stream command
|
|
945
|
+
res.write(`D:{"type":"redirect","url":${safeUrl}}\n`);
|
|
950
946
|
|
|
951
947
|
// ⚠️ IMPORTANT:
|
|
952
948
|
// 1. We close the response, since we redirected and there will be no RSC payload.
|
package/dinou/package.json
CHANGED