next-tinacms-s3 23.0.3 → 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
@@ -7,7 +7,85 @@ import {
7
7
  HeadObjectCommand
8
8
  } from "@aws-sdk/client-s3";
9
9
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
10
+ import path2 from "node:path";
11
+
12
+ // src/media-key.ts
10
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
  var mediaHandlerConfig = {
12
90
  api: {
13
91
  bodyParser: false
@@ -38,10 +116,17 @@ var createMediaHandler = (config, options) => {
38
116
  switch (req.method) {
39
117
  case "GET":
40
118
  if (req.query.key) {
41
- const expiresIn = req.query.expiresIn && Number(req.query.expiresIn) || 3600;
42
- const s3_key = req.query.key ? Array.isArray(req.query.key) ? req.query.key[0] : req.query.key : null;
43
- if (!s3_key) {
44
- return res.status(400).json({ message: "key is required" });
119
+ const requestedExpiresIn = Number(req.query.expiresIn);
120
+ const expiresIn = Number.isFinite(requestedExpiresIn) && requestedExpiresIn > 0 ? Math.min(requestedExpiresIn, 3600) : 3600;
121
+ const rawKey = Array.isArray(req.query.key) ? req.query.key[0] : req.query.key;
122
+ let s3_key;
123
+ try {
124
+ s3_key = resolveKey(mediaRoot, rawKey, { decode: false });
125
+ } catch (e) {
126
+ if (e instanceof MediaKeyError) {
127
+ return res.status(400).json({ message: e.message });
128
+ }
129
+ throw e;
45
130
  }
46
131
  if (await keyExists(client, bucket, s3_key)) {
47
132
  return res.status(400).json({ message: "key already exists" });
@@ -56,7 +141,7 @@ var createMediaHandler = (config, options) => {
56
141
  }
57
142
  return listMedia(req, res, client, bucket, mediaRoot, cdnUrl);
58
143
  case "DELETE":
59
- return deleteAsset(req, res, client, bucket);
144
+ return deleteAsset(req, res, client, bucket, mediaRoot);
60
145
  default:
61
146
  res.end(404);
62
147
  }
@@ -85,12 +170,20 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
85
170
  limit = 500,
86
171
  offset
87
172
  } = req.query;
88
- let prefix = directory.replace(/^\//, "").replace(/\/$/, "");
89
- if (prefix) prefix = prefix + "/";
173
+ let prefix;
174
+ try {
175
+ prefix = resolveDirectory(directory);
176
+ } catch (e) {
177
+ if (e instanceof MediaKeyError) {
178
+ res.status(400).json({ message: e.message });
179
+ return;
180
+ }
181
+ throw e;
182
+ }
90
183
  const params = {
91
184
  Bucket: bucket,
92
185
  Delimiter: "/",
93
- Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix,
186
+ Prefix: mediaRoot ? path2.join(mediaRoot, prefix) : prefix,
94
187
  Marker: offset?.toString(),
95
188
  MaxKeys: directory && !offset ? +limit + 1 : +limit
96
189
  };
@@ -105,8 +198,8 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
105
198
  items.push({
106
199
  id: Prefix,
107
200
  type: "dir",
108
- filename: path.basename(strippedPrefix),
109
- directory: path.dirname(strippedPrefix)
201
+ filename: path2.basename(strippedPrefix),
202
+ directory: path2.dirname(strippedPrefix)
110
203
  });
111
204
  });
112
205
  items.push(
@@ -133,9 +226,18 @@ var findErrorMessage = (e) => {
133
226
  if (e.error && e.error.message) return e.error.message;
134
227
  return "an error occurred";
135
228
  };
136
- async function deleteAsset(req, res, client, bucket) {
229
+ async function deleteAsset(req, res, client, bucket, mediaRoot) {
137
230
  const { media } = req.query;
138
- const [, objectKey] = media;
231
+ const [, rawKey] = media;
232
+ let objectKey;
233
+ try {
234
+ objectKey = resolveKey(mediaRoot, rawKey, { decode: false });
235
+ } catch (e) {
236
+ if (e instanceof MediaKeyError) {
237
+ return res.status(400).json({ message: e.message });
238
+ }
239
+ throw e;
240
+ }
139
241
  const params = {
140
242
  Bucket: bucket,
141
243
  Key: objectKey
@@ -183,8 +285,8 @@ var getUploadUrl = async (bucket, key, expiresIn, client) => {
183
285
  function getS3ToTinaFunc(cdnUrl, mediaRoot) {
184
286
  return function s3ToTina(file) {
185
287
  const strippedKey = stripMediaRoot(mediaRoot, file.Key);
186
- const filename = path.basename(strippedKey);
187
- const directory = path.dirname(strippedKey) + "/";
288
+ const filename = path2.basename(strippedKey);
289
+ const directory = path2.dirname(strippedKey) + "/";
188
290
  const src = cdnUrl + file.Key;
189
291
  return {
190
292
  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-s3",
3
3
  "type": "module",
4
- "version": "23.0.3",
4
+ "version": "23.0.4",
5
5
  "main": "dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "files": [
@@ -27,11 +27,11 @@
27
27
  "react": "^18.3.1",
28
28
  "react-dom": "^18.3.1",
29
29
  "typescript": "^5.7.3",
30
- "@tinacms/scripts": "1.6.1",
31
- "tinacms": "3.9.3"
30
+ "@tinacms/scripts": "1.6.2",
31
+ "tinacms": "3.9.4"
32
32
  },
33
33
  "peerDependencies": {
34
- "tinacms": "3.9.3"
34
+ "tinacms": "3.9.4"
35
35
  },
36
36
  "publishConfig": {
37
37
  "registry": "https://registry.npmjs.org"