dinou 4.0.10 → 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 CHANGED
@@ -5,6 +5,21 @@ 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
+
17
+ ## [4.0.11]
18
+
19
+ ### Fixed
20
+
21
+ - Security: Fix server functions endpoint vulnerability and add improvements.
22
+
8
23
  ## [4.0.10]
9
24
 
10
25
  ### 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
- // Case 1: Pure HTML (Simple Redirect)
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
- const range = document.createRange();
39
- const documentFragment = range.createContextualFragment(html);
40
- document.body.appendChild(documentFragment);
41
- return new Promise(() => { });
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
- // CHECK: We search for the tag start, whether it has the closing > or not
64
- if (buffer.includes("<script")) {
65
- console.warn(
66
- "[Dinou] Stream ended with incomplete script. Discarding tail.",
67
- );
68
- // We do not enqueue.
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 SCRIPTS
81
- // We search for complete pairs of <script>...</script>
82
- let match;
83
-
84
- // We execute all complete scripts we find
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
- // Detect redirect
90
- if (scriptContent.includes("window.location.href")) {
91
- isRedirecting = true;
92
- }
104
+ const lines = completeChunk.split("\n");
105
+ let cleanChunk = "";
93
106
 
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
- }
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
- // 3. CLEAN PROCESSED SCRIPTS FROM BUFFER
101
- // Once executed, we remove them so they don't go to React
102
- buffer = buffer.replace(scriptRegex, "");
103
-
104
- // 4. CALCULATE WHAT IS SAFE TO SEND (The anti-cut logic)
105
- // We need to know if the buffer ends with something that LOOKS like the start of a script
106
- // Dangerous patterns at the end: <, <s, <sc, <scr, <scri, <scrip, <script
107
-
108
- let cutoffIndex = buffer.length; // Default send everything
109
-
110
- // A) If there is an open but not closed <script> in the buffer
111
- const openScriptIndex = buffer.indexOf("<script>");
112
- if (openScriptIndex !== -1) {
113
- // We keep everything from the <script> onwards
114
- cutoffIndex = openScriptIndex;
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
- // 5. SEND SAFE CONTENT
127
- const safeChunk = buffer.slice(0, cutoffIndex);
128
- // What remains stays in the buffer for the next loop (chunk)
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
- // Case 1: Pure HTML (Simple Redirect)
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
- const range = document.createRange();
40
- const documentFragment = range.createContextualFragment(html);
41
- document.body.appendChild(documentFragment);
42
- return new Promise(() => {});
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
- // CHECK: We search for the tag start, whether it has the closing > or not
65
- if (buffer.includes("<script")) {
66
- console.warn(
67
- "[Dinou] Stream ended with incomplete script. Discarding tail.",
68
- );
69
- // We do not enqueue.
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 SCRIPTS
82
- // We search for complete pairs of <script>...</script>
83
- let match;
84
-
85
- // We execute all complete scripts we find
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
- // Detect redirect
91
- if (scriptContent.includes("window.location.href")) {
92
- isRedirecting = true;
93
- }
105
+ const lines = completeChunk.split("\n");
106
+ let cleanChunk = "";
94
107
 
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
- }
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
- // 3. CLEAN PROCESSED SCRIPTS FROM BUFFER
102
- // Once executed, we remove them so they don't go to React
103
- buffer = buffer.replace(scriptRegex, "");
104
-
105
- // 4. CALCULATE WHAT IS SAFE TO SEND (The anti-cut logic)
106
- // We need to know if the buffer ends with something that LOOKS like the start of a script
107
- // Dangerous patterns at the end: <, <s, <sc, <scr, <scri, <scrip, <script
108
-
109
- let cutoffIndex = buffer.length; // Default send everything
110
-
111
- // A) If there is an open but not closed <script> in the buffer
112
- const openScriptIndex = buffer.indexOf("<script>");
113
- if (openScriptIndex !== -1) {
114
- // We keep everything from the <script> onwards
115
- cutoffIndex = openScriptIndex;
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
- // 5. SEND SAFE CONTENT
128
- const safeChunk = buffer.slice(0, cutoffIndex);
129
- // What remains stays in the buffer for the next loop (chunk)
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
  }
@@ -380,7 +380,7 @@ function getContextForServerFunctionEndpoint(req, res) {
380
380
  }
381
381
 
382
382
  // SCENARIO B: Streaming active (Headers Sent)
383
- // Inject JavaScript.
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 injection
407
+ // Safe packaging for stream command
408
408
  const safeCookieStr = JSON.stringify(cookieStr);
409
409
 
410
410
  // Write to stream
411
- res.write(`<script>document.cookie = ${safeCookieStr};</script>`);
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: Script Injection
425
- const safeName = JSON.stringify(name);
426
- const safePath = JSON.stringify(path);
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
  };
@@ -752,7 +749,7 @@ function isOriginAllowed(req) {
752
749
  try {
753
750
  // Parse to ignore protocol (http/https) and port if they differ subtly
754
751
  const originHost = new URL(origin).host;
755
- const serverHost = req.headers.host;
752
+ const serverHost = req.headers["x-forwarded-host"] || req.headers.host;
756
753
 
757
754
  // Compare host (domain:port)
758
755
  return originHost === serverHost;
@@ -782,7 +779,7 @@ app.post("/____server_function____", async (req, res) => {
782
779
 
783
780
  // 1. Check Origin (Prevent calls from other domains)
784
781
  const origin = req.headers.origin;
785
- const host = req.headers.host;
782
+ const host = req.headers["x-forwarded-host"] || req.headers.host;
786
783
 
787
784
  // Note: Locally sometimes origin is undefined or null, allow in dev if necessary
788
785
  if (!isDevelopment && origin && !origin.includes(host)) {
@@ -851,10 +848,15 @@ app.post("/____server_function____", async (req, res) => {
851
848
 
852
849
  // Extract relativePath and normalize (remove 'file://' and potential '/')
853
850
  let relativePath = fileUrl.replace(/^file:\/\/\/?/, "").trim();
854
- if (relativePath.startsWith("/") || relativePath.includes("..")) {
851
+ const normalizedRelative = relativePath.replace(/\\/g, "/");
852
+ if (
853
+ normalizedRelative.startsWith("/") ||
854
+ normalizedRelative.includes("..") ||
855
+ normalizedRelative.includes(":")
856
+ ) {
855
857
  return res
856
858
  .status(400)
857
- .json({ error: "Invalid path: no absolute or traversal allowed" });
859
+ .json({ error: "Invalid path: no absolute, traversal, or drive letter allowed" });
858
860
  }
859
861
  // console.log("relPath", relativePath);
860
862
  // Restrict to 'src/' folder: prepend 'src/' if missing, and resolve absolutePath
@@ -881,6 +883,11 @@ app.post("/____server_function____", async (req, res) => {
881
883
  // Prod: use manifest (relativePath is already normalized)
882
884
  allowedExports = serverFunctionsManifest[relativePath];
883
885
  } else {
886
+ if (!isDevelopment) {
887
+ return res
888
+ .status(403)
889
+ .json({ error: "Access denied: Manifest missing in production" });
890
+ }
884
891
  const fileContent = readFileSync(absolutePath, "utf8"); // Reads only once
885
892
  if (!useServerRegex.test(fileContent)) {
886
893
  return res
@@ -925,18 +932,17 @@ app.post("/____server_function____", async (req, res) => {
925
932
  } catch (err) {
926
933
  // 💡 WE INTERCEPT THE REDIRECT
927
934
  if (err && err.$$type === "dinou-internal-redirect") {
928
- // 1. Always sanitize the URL
929
935
  const safeUrl = JSON.stringify(err.url);
930
- const script = `<script>window.location.href = ${safeUrl};</script>`;
931
936
 
932
937
  if (!res.headersSent) {
933
- // SCENARIO A: Clean (Content-Type html)
934
- res.setHeader("Content-Type", "text/html");
935
- return res.send(script); // res.send calls end() and return stops the function
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 });
936
942
  } else {
937
- // SCENARIO B: Dirty/Active Stream (Content-Type already set by clearCookie)
938
- // Write the script to the existing stream
939
- res.write(script);
943
+ // SCENARIO B: Dirty/Active Stream
944
+ // Write a custom line-based stream command
945
+ res.write(`D:{"type":"redirect","url":${safeUrl}}\n`);
940
946
 
941
947
  // ⚠️ IMPORTANT:
942
948
  // 1. We close the response, since we redirected and there will be no RSC payload.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou-ejected",
3
- "version": "4.0.10",
3
+ "version": "4.0.12",
4
4
  "main": "index.js",
5
5
  "private": true,
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou",
3
- "version": "4.0.10",
3
+ "version": "4.0.12",
4
4
  "description": "Full-Stack React 19 framework with React Server Components, Server Functions, and Streaming SSR.",
5
5
  "main": "index.js",
6
6
  "bin": {