next-tinacms-dos 23.0.2 → 23.0.4

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/dist/handlers.js CHANGED
@@ -5,9 +5,87 @@ import {
5
5
  PutObjectCommand,
6
6
  DeleteObjectCommand
7
7
  } from "@aws-sdk/client-s3";
8
- import path from "path";
8
+ import path2 from "path";
9
9
  import fs from "fs";
10
10
  import multer from "multer";
11
+
12
+ // src/media-key.ts
13
+ import path from "node:path";
14
+ var MediaKeyError = class extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "MediaKeyError";
18
+ }
19
+ };
20
+ function stripSlashes(value) {
21
+ let start = 0;
22
+ let end = value.length;
23
+ while (start < end && value[start] === "/") start++;
24
+ while (end > start && value[end - 1] === "/") end--;
25
+ return value.slice(start, end);
26
+ }
27
+ function normalizeMediaRoot(mediaRoot) {
28
+ if (!mediaRoot) {
29
+ return "";
30
+ }
31
+ return stripSlashes(mediaRoot);
32
+ }
33
+ function resolveKey(mediaRoot, rawKey, options) {
34
+ if (typeof rawKey !== "string" || rawKey.trim() === "") {
35
+ throw new MediaKeyError("a media key is required");
36
+ }
37
+ let decoded;
38
+ if (options?.decode === false) {
39
+ decoded = rawKey;
40
+ } else {
41
+ try {
42
+ decoded = decodeURIComponent(rawKey);
43
+ } catch {
44
+ throw new MediaKeyError("media key is not valid");
45
+ }
46
+ }
47
+ if (decoded.includes("\0") || decoded.includes("\\")) {
48
+ throw new MediaKeyError("media key is not valid");
49
+ }
50
+ if (decoded.startsWith("/") || /^[a-zA-Z]:/.test(decoded)) {
51
+ throw new MediaKeyError("absolute media keys are not allowed");
52
+ }
53
+ const normalized = path.posix.normalize(decoded).replace(/^\.\//, "");
54
+ if (normalized === ".." || normalized.startsWith("../")) {
55
+ throw new MediaKeyError("media key may not traverse directories");
56
+ }
57
+ if (normalized === "" || normalized === ".") {
58
+ throw new MediaKeyError("a media key is required");
59
+ }
60
+ const root = normalizeMediaRoot(mediaRoot);
61
+ if (!root) {
62
+ return normalized;
63
+ }
64
+ const scoped = normalized === root || normalized.startsWith(root + "/") ? normalized : path.posix.join(root, normalized);
65
+ if (scoped !== root && !scoped.startsWith(root + "/")) {
66
+ throw new MediaKeyError("media key escapes mediaRoot");
67
+ }
68
+ return scoped;
69
+ }
70
+ function resolveDirectory(rawDirectory) {
71
+ if (typeof rawDirectory !== "string" || rawDirectory.trim() === "") {
72
+ return "";
73
+ }
74
+ if (rawDirectory.includes("\0") || rawDirectory.includes("\\")) {
75
+ throw new MediaKeyError("media directory is not valid");
76
+ }
77
+ const trimmed = stripSlashes(rawDirectory);
78
+ const normalized = path.posix.normalize(trimmed).replace(/^\.\//, "");
79
+ if (normalized === ".." || normalized.startsWith("../")) {
80
+ throw new MediaKeyError("media directory may not traverse directories");
81
+ }
82
+ if (normalized === "" || normalized === ".") {
83
+ return "";
84
+ }
85
+ return normalized + "/";
86
+ }
87
+
88
+ // src/handlers.ts
11
89
  import { promisify } from "util";
12
90
  var mediaHandlerConfig = {
13
91
  api: {
@@ -40,7 +118,7 @@ var createMediaHandler = (config, options) => {
40
118
  case "POST":
41
119
  return uploadMedia(req, res, client, bucket, mediaRoot, cdnUrl);
42
120
  case "DELETE":
43
- return deleteAsset(req, res, client, bucket);
121
+ return deleteAsset(req, res, client, bucket, mediaRoot);
44
122
  default:
45
123
  res.end(404);
46
124
  }
@@ -68,10 +146,19 @@ async function uploadMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
68
146
  const filePath = req.file.path;
69
147
  const fileType = req.file?.mimetype;
70
148
  const blob = fs.readFileSync(filePath);
71
- const filename = path.basename(filePath);
149
+ const filename = path2.basename(filePath);
150
+ let objectKey;
151
+ try {
152
+ objectKey = resolveKey(mediaRoot, prefix + filename, { decode: false });
153
+ } catch (e) {
154
+ if (e instanceof MediaKeyError) {
155
+ return res.status(400).json({ message: e.message });
156
+ }
157
+ throw e;
158
+ }
72
159
  const params = {
73
160
  Bucket: bucket,
74
- Key: mediaRoot ? path.join(mediaRoot, prefix + filename) : prefix + filename,
161
+ Key: objectKey,
75
162
  Body: blob,
76
163
  ACL: "public-read",
77
164
  ContentType: fileType || "application/octet-stream"
@@ -90,7 +177,7 @@ async function uploadMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
90
177
  "400x400": src,
91
178
  "1000x1000": src
92
179
  },
93
- src: cdnUrl + (mediaRoot ? path.join(mediaRoot, prefix + filename) : prefix + filename)
180
+ src: cdnUrl + objectKey
94
181
  });
95
182
  } catch (e) {
96
183
  console.error("Error uploading media");
@@ -121,12 +208,20 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
121
208
  limit = 500,
122
209
  offset
123
210
  } = req.query;
124
- let prefix = directory.replace(/^\//, "").replace(/\/$/, "");
125
- if (prefix) prefix = prefix + "/";
211
+ let prefix;
212
+ try {
213
+ prefix = resolveDirectory(directory);
214
+ } catch (e) {
215
+ if (e instanceof MediaKeyError) {
216
+ res.status(400).json({ message: e.message });
217
+ return;
218
+ }
219
+ throw e;
220
+ }
126
221
  const params = {
127
222
  Bucket: bucket,
128
223
  Delimiter: "/",
129
- Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix,
224
+ Prefix: mediaRoot ? path2.join(mediaRoot, prefix) : prefix,
130
225
  Marker: offset?.toString(),
131
226
  MaxKeys: directory && !offset ? +limit + 1 : +limit
132
227
  };
@@ -140,8 +235,8 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
140
235
  items.push({
141
236
  id: Prefix,
142
237
  type: "dir",
143
- filename: path.basename(strippedPrefix),
144
- directory: path.dirname(strippedPrefix)
238
+ filename: path2.basename(strippedPrefix),
239
+ directory: path2.dirname(strippedPrefix)
145
240
  });
146
241
  });
147
242
  items.push(
@@ -168,12 +263,21 @@ var findErrorMessage = (e) => {
168
263
  if (e.error && e.error.message) return e.error.message;
169
264
  return "an error occurred";
170
265
  };
171
- async function deleteAsset(req, res, client, bucket) {
266
+ async function deleteAsset(req, res, client, bucket, mediaRoot) {
172
267
  const { media } = req.query;
173
- let [, objectKey] = media;
268
+ let [, rawKey] = media;
174
269
  const objectKeyIsSplit = media && media.length > 2 && typeof media !== "string";
175
270
  if (objectKeyIsSplit) {
176
- objectKey = media.slice(1).join("/");
271
+ rawKey = media.slice(1).join("/");
272
+ }
273
+ let objectKey;
274
+ try {
275
+ objectKey = resolveKey(mediaRoot, rawKey, { decode: false });
276
+ } catch (e) {
277
+ if (e instanceof MediaKeyError) {
278
+ return res.status(400).json({ message: e.message });
279
+ }
280
+ throw e;
177
281
  }
178
282
  const params = {
179
283
  Bucket: bucket,
@@ -193,8 +297,8 @@ async function deleteAsset(req, res, client, bucket) {
193
297
  function getDOSToTinaFunc(cdnUrl, mediaRoot) {
194
298
  return function dosToTina(file) {
195
299
  const strippedKey = stripMediaRoot(mediaRoot, file.Key);
196
- const filename = path.basename(strippedKey);
197
- const directory = path.dirname(strippedKey) + "/";
300
+ const filename = path2.basename(strippedKey);
301
+ const directory = path2.dirname(strippedKey) + "/";
198
302
  const src = cdnUrl + file.Key;
199
303
  return {
200
304
  id: file.Key,
@@ -0,0 +1,26 @@
1
+ export declare class MediaKeyError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Resolve a caller-supplied media key against the configured mediaRoot.
6
+ *
7
+ * @throws {MediaKeyError} when the key is empty, absolute, contains a NUL
8
+ * byte, uses path traversal, or escapes mediaRoot.
9
+ */
10
+ export declare function resolveKey(mediaRoot: string, rawKey: unknown, options?: {
11
+ decode?: boolean;
12
+ }): string;
13
+ /**
14
+ * Resolve a caller-supplied listing directory into a relative prefix.
15
+ *
16
+ * The directory is optional: an empty / root directory returns `''` (list the
17
+ * mediaRoot itself). Otherwise leading/trailing slashes are stripped (matching
18
+ * the historical handler behaviour) and the result is normalised so it cannot
19
+ * traverse upward — `path.join(mediaRoot, directory)` would otherwise collapse
20
+ * `..` and list objects outside mediaRoot. Returns a prefix with a trailing
21
+ * slash so it can be joined onto mediaRoot directly.
22
+ *
23
+ * @throws {MediaKeyError} when the directory contains a NUL byte or backslash,
24
+ * or uses upward path traversal.
25
+ */
26
+ export declare function resolveDirectory(rawDirectory: unknown): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "next-tinacms-dos",
3
3
  "type": "module",
4
- "version": "23.0.2",
4
+ "version": "23.0.4",
5
5
  "main": "dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "files": [
@@ -19,7 +19,7 @@
19
19
  ]
20
20
  },
21
21
  "peerDependencies": {
22
- "tinacms": "3.9.2"
22
+ "tinacms": "3.9.4"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/crypto-js": "^3.1.47",
@@ -29,8 +29,8 @@
29
29
  "react": "^18.3.1",
30
30
  "react-dom": "^18.3.1",
31
31
  "typescript": "^5.7.3",
32
- "@tinacms/scripts": "1.6.1",
33
- "tinacms": "3.9.2"
32
+ "tinacms": "3.9.4",
33
+ "@tinacms/scripts": "1.6.2"
34
34
  },
35
35
  "publishConfig": {
36
36
  "registry": "https://registry.npmjs.org"