@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.23

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.
@@ -1,8 +1,25 @@
1
+ /** @module nodes/lib/assets */
2
+
1
3
  /**
2
4
  * Portal Assets — static file serving with security validation.
3
5
  *
4
- * Exports pure validation functions (for testing) and a registerAssets factory
5
- * that mounts Express routes on RED.httpAdmin / RED.httpNode.
6
+ * Exports pure validators (for unit-testing without RED) and a
7
+ * `registerAssets` factory that mounts Express routes on `RED.httpAdmin`
8
+ * (admin CRUD, auth-gated) and `RED.httpNode` (public read-only serving).
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} AssetEntry
13
+ * @property {string} name Relative path (POSIX, forward slashes).
14
+ * @property {"file"|"dir"} type
15
+ * @property {number} [size] Bytes — file only.
16
+ * @property {number} [mtime] fs.statSync.mtimeMs — file only.
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} AssetsStats
21
+ * @property {number} size Total bytes across all files (excluding symlinks).
22
+ * @property {number} count Total file count.
6
23
  */
7
24
 
8
25
  const fs = require("fs");
@@ -18,6 +35,15 @@ const MAX_ASSETS_FILES = 1000;
18
35
 
19
36
  // ── Pure validators ───────────────────────────────────────────
20
37
 
38
+ /**
39
+ * True when `s` is a single path segment safe to use under the assets root:
40
+ * non-empty string ≤255 chars, no Windows-illegal characters (`\:*?"<>|\0`),
41
+ * not a Windows reserved name (CON, PRN, …), no leading dot, no trailing
42
+ * `.` or space, and not the special `..` / `.` traversal markers.
43
+ *
44
+ * @param {*} s
45
+ * @returns {boolean}
46
+ */
21
47
  function isSafePathSegment(s) {
22
48
  return (
23
49
  typeof s === "string" &&
@@ -32,8 +58,35 @@ function isSafePathSegment(s) {
32
58
  );
33
59
  }
34
60
 
61
+ // Combined relative-path cap. MAX_PATH_DEPTH=10 segments already constrains
62
+ // fan-out; this bounds total characters so an attacker can't craft a 10-segment
63
+ // path of 255-char names (~2.5 KB) that defeats higher-level URL length checks.
64
+ const MAX_REL_PATH_LEN = 1024;
65
+
66
+ /**
67
+ * Resolve a user-supplied relative path against `assetsDir`, applying every
68
+ * layer of path-traversal protection:
69
+ *
70
+ * 1. Non-empty string input.
71
+ * 2. Total length ≤ MAX_REL_PATH_LEN bytes.
72
+ * 3. Split on "/" and reject empty segments — `..`, `.`, `\0`, Windows
73
+ * reserved names (CON/PRN/AUX/NUL/COM1-9/LPT1-9), invalid characters,
74
+ * dotfiles, names ending in space/dot, length > 255 — see
75
+ * `isSafePathSegment`.
76
+ * 4. Segment count ≤ MAX_PATH_DEPTH.
77
+ * 5. After `path.resolve`, the resulting absolute path must stay inside
78
+ * `assetsDir + path.sep`.
79
+ * 6. If the path already exists on disk, `fs.realpathSync` must also stay
80
+ * inside `assetsDir` — blocks symlink-escape attacks.
81
+ *
82
+ * @param {string} rel
83
+ * @param {string} assetsDir
84
+ * @returns {string|null} Absolute resolved path, or null if any check fails.
85
+ */
35
86
  function safePath(rel, assetsDir) {
36
87
  if (!rel || typeof rel !== "string") return null;
88
+ if (rel.length > MAX_REL_PATH_LEN) return null;
89
+ if (rel.indexOf("\0") !== -1) return null;
37
90
  const segments = rel.split("/").filter(Boolean);
38
91
  if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
39
92
  if (!segments.every(isSafePathSegment)) return null;
@@ -50,6 +103,15 @@ function safePath(rel, assetsDir) {
50
103
 
51
104
  // ── Filesystem helpers ────────────────────────────────────────
52
105
 
106
+ /**
107
+ * Recursively list the contents of an assets directory. Symbolic links are
108
+ * skipped to defend against symlink-escape. Names are returned relative to
109
+ * the initial call (using `/` as separator).
110
+ *
111
+ * @param {string} dir Absolute filesystem directory to scan.
112
+ * @param {string} [prefix] Path prefix accumulated by recursion ("" at the root).
113
+ * @returns {Array<AssetEntry>}
114
+ */
53
115
  function scanDir(dir, prefix) {
54
116
  const results = [];
55
117
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -66,6 +128,15 @@ function scanDir(dir, prefix) {
66
128
  return results;
67
129
  }
68
130
 
131
+ /**
132
+ * Compute total file count and aggregate byte size under the assets root.
133
+ * Used to enforce the per-portal `MAX_ASSETS_BYTES` / `MAX_ASSETS_FILES`
134
+ * quotas before accepting an upload. Symbolic links are skipped. Filesystem
135
+ * errors (e.g. assets dir doesn't exist yet) yield `{size:0, count:0}`.
136
+ *
137
+ * @param {string} assetsDir
138
+ * @returns {{size: number, count: number}}
139
+ */
69
140
  function getAssetsStats(assetsDir) {
70
141
  let size = 0, count = 0;
71
142
  function walk(dir) {
@@ -82,9 +153,54 @@ function getAssetsStats(assetsDir) {
82
153
 
83
154
  // ── Route registration ────────────────────────────────────────
84
155
 
85
- function registerAssets(RED, express, assetsDir) {
156
+ /**
157
+ * Resolve a `RED.auth.needsPermission(scope)` middleware, with a no-op fallback
158
+ * for runtimes (older Node-RED versions / test harnesses) where `RED.auth` is
159
+ * absent. Admin endpoints would otherwise be unprotected when an editor
160
+ * `adminAuth` is configured.
161
+ *
162
+ * @param {Object} RED
163
+ * @param {string} scope Permission name, e.g. `"portal-react.write"`.
164
+ * @returns {Function} Express middleware.
165
+ */
166
+ function authMiddleware(RED, scope) {
167
+ if (RED.auth && typeof RED.auth.needsPermission === "function") {
168
+ return RED.auth.needsPermission(scope);
169
+ }
170
+ return function (_req, _res, next) { next(); };
171
+ }
172
+
173
+ /**
174
+ * Mount the Assets HTTP API on Node-RED's admin (write paths, auth-gated) and
175
+ * node (public read-only static) HTTP routers.
176
+ *
177
+ * @param {Object} RED Node-RED runtime object.
178
+ * @param {import('express')} express Express factory (for json/raw/static).
179
+ * @param {string} assetsDir Absolute filesystem path of the assets root.
180
+ * @param {Object} [opts] Optional middleware hooks supplied by the
181
+ * host module (CSRF, rate-limit) so the
182
+ * same protections apply to assets POSTs.
183
+ * @param {Function} [opts.csrfGuard]
184
+ * @param {Function} [opts.rateLimit]
185
+ * @param {string} [opts.jsonLimit] Body-parser limit (default "1mb").
186
+ * @returns {void}
187
+ *
188
+ * @fires RED.log#info on first-time directory creation
189
+ *
190
+ * @example
191
+ * const { registerAssets } = require("./lib/assets");
192
+ * registerAssets(RED, require("express"), path.join(userDir, "fromcubes", "public"));
193
+ */
194
+ function registerAssets(RED, express, assetsDir, opts) {
86
195
  fs.mkdirSync(assetsDir, { recursive: true });
87
196
 
197
+ const READ = authMiddleware(RED, "portal-react.read");
198
+ const WRITE = authMiddleware(RED, "portal-react.write");
199
+ const passthrough = (_req, _res, next) => next();
200
+ const csrfGuard = (opts && opts.csrfGuard) || passthrough;
201
+ const rateLimit = (opts && opts.rateLimit) || passthrough;
202
+ const jsonLimit = (opts && opts.jsonLimit) || "1mb";
203
+
88
204
  // Security middleware for public serving
89
205
  RED.httpNode.use(
90
206
  "/fromcubes/public",
@@ -97,20 +213,24 @@ function registerAssets(RED, express, assetsDir) {
97
213
  }
98
214
  next();
99
215
  },
100
- express.static(assetsDir, { maxAge: "1d" }),
216
+ // dotfiles: 'deny' → never serve .git/, .htaccess, .env, etc. that may
217
+ // have been written into assetsDir by mistake. fallthrough: false makes
218
+ // express.static return 404 directly instead of leaking to the next mw.
219
+ express.static(assetsDir, { maxAge: "1d", dotfiles: "deny", fallthrough: false }),
101
220
  );
102
221
 
103
222
  // List assets
104
- RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
223
+ RED.httpAdmin.get("/portal-react/assets", READ, (_req, res) => {
105
224
  try {
106
225
  res.json(scanDir(assetsDir, ""));
107
226
  } catch (e) {
227
+ RED.log.error("portal-react assets list: " + e.message);
108
228
  res.json([]);
109
229
  }
110
230
  });
111
231
 
112
232
  // Create directory
113
- RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
233
+ RED.httpAdmin.post("/portal-react/assets/mkdir", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
114
234
  const target = safePath(req.body && req.body.path, assetsDir);
115
235
  if (!target) return res.status(400).json({ error: "invalid path" });
116
236
  try {
@@ -123,7 +243,7 @@ function registerAssets(RED, express, assetsDir) {
123
243
  });
124
244
 
125
245
  // Move / rename
126
- RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
246
+ RED.httpAdmin.post("/portal-react/assets/move", WRITE, csrfGuard, rateLimit, express.json({ limit: jsonLimit }), (req, res) => {
127
247
  const from = safePath(req.body && req.body.from, assetsDir);
128
248
  const to = safePath(req.body && req.body.to, assetsDir);
129
249
  if (!from || !to) return res.status(400).json({ error: "invalid path" });
@@ -140,16 +260,25 @@ function registerAssets(RED, express, assetsDir) {
140
260
  }
141
261
  });
142
262
 
143
- // Upload
263
+ // Upload — 100mb cap is intentionally higher than json/text endpoints.
144
264
  RED.httpAdmin.post(
145
265
  "/portal-react/assets/upload/*",
266
+ WRITE,
267
+ csrfGuard,
268
+ rateLimit,
146
269
  express.raw({ type: "*/*", limit: "100mb" }),
147
270
  (req, res) => {
148
271
  const rel = req.params[0];
149
272
  const target = safePath(rel, assetsDir);
150
273
  if (!target) return res.status(400).json({ error: "invalid path" });
274
+ // Guard: when nothing was uploaded (Content-Length 0 or wrong type),
275
+ // express.raw yields an empty {} object instead of a Buffer.
276
+ const bodyLen = Buffer.isBuffer(req.body) ? req.body.length : 0;
277
+ if (bodyLen === 0) {
278
+ return res.status(400).json({ error: "empty upload" });
279
+ }
151
280
  const stats = getAssetsStats(assetsDir);
152
- if (stats.size + req.body.length > MAX_ASSETS_BYTES)
281
+ if (stats.size + bodyLen > MAX_ASSETS_BYTES)
153
282
  return res.status(413).json({ error: "storage limit exceeded (500MB)" });
154
283
  if (stats.count >= MAX_ASSETS_FILES)
155
284
  return res.status(413).json({ error: "file count limit exceeded (1000)" });
@@ -165,7 +294,7 @@ function registerAssets(RED, express, assetsDir) {
165
294
  );
166
295
 
167
296
  // Delete
168
- RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
297
+ RED.httpAdmin.delete("/portal-react/assets/*", WRITE, csrfGuard, rateLimit, (req, res) => {
169
298
  const rel = req.params[0];
170
299
  const target = safePath(rel, assetsDir);
171
300
  if (!target) return res.status(400).json({ error: "invalid path" });
@@ -179,7 +308,7 @@ function registerAssets(RED, express, assetsDir) {
179
308
  });
180
309
 
181
310
  // Download
182
- RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
311
+ RED.httpAdmin.get("/portal-react/assets/download/*", READ, (req, res) => {
183
312
  const rel = req.params[0];
184
313
  const target = safePath(rel, assetsDir);
185
314
  if (!target) return res.status(400).json({ error: "invalid path" });
@@ -202,6 +331,7 @@ module.exports = {
202
331
  UNSAFE_EXTS,
203
332
  RESERVED_NAMES,
204
333
  MAX_PATH_DEPTH,
334
+ MAX_REL_PATH_LEN,
205
335
  MAX_ASSETS_BYTES,
206
336
  MAX_ASSETS_FILES,
207
337
  isSafePathSegment,