@tinacms/cli 2.1.6 → 2.1.7

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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Cli, Builtins } from "clipanion";
3
3
 
4
4
  // package.json
5
- var version = "2.1.6";
5
+ var version = "2.1.7";
6
6
 
7
7
  // src/next/commands/dev-command/index.ts
8
8
  import path8 from "path";
@@ -817,6 +817,14 @@ function stripNativeTrailingSlash(p) {
817
817
  }
818
818
  return str;
819
819
  }
820
+ var PathTraversalError = class extends Error {
821
+ constructor(attemptedPath) {
822
+ super(
823
+ `Path traversal detected: the path "${attemptedPath}" escapes the allowed directory`
824
+ );
825
+ this.name = "PathTraversalError";
826
+ }
827
+ };
820
828
 
821
829
  // src/next/config-manager.ts
822
830
  var TINA_FOLDER = "tina";
@@ -2063,9 +2071,9 @@ import cors from "cors";
2063
2071
  import { resolve as gqlResolve } from "@tinacms/graphql";
2064
2072
 
2065
2073
  // src/next/commands/dev-command/server/media.ts
2066
- import fs5 from "fs-extra";
2067
2074
  import path6, { join } from "path";
2068
2075
  import busboy from "busboy";
2076
+ import fs5 from "fs-extra";
2069
2077
  var createMediaRouter = (config2) => {
2070
2078
  const mediaFolder = path6.join(
2071
2079
  config2.rootPath,
@@ -2074,31 +2082,68 @@ var createMediaRouter = (config2) => {
2074
2082
  );
2075
2083
  const mediaModel = new MediaModel(config2);
2076
2084
  const handleList = async (req, res) => {
2077
- const requestURL = new URL(req.url, config2.apiURL);
2078
- const folder = requestURL.pathname.replace("/media/list/", "");
2079
- const limit = requestURL.searchParams.get("limit");
2080
- const cursor = requestURL.searchParams.get("cursor");
2081
- const media = await mediaModel.listMedia({
2082
- searchPath: folder,
2083
- cursor,
2084
- limit
2085
- });
2086
- res.end(JSON.stringify(media));
2085
+ try {
2086
+ const requestURL = new URL(req.url, config2.apiURL);
2087
+ const folder = decodeURIComponent(
2088
+ requestURL.pathname.replace("/media/list/", "")
2089
+ );
2090
+ const limit = requestURL.searchParams.get("limit");
2091
+ const cursor = requestURL.searchParams.get("cursor");
2092
+ const media = await mediaModel.listMedia({
2093
+ searchPath: folder,
2094
+ cursor,
2095
+ limit
2096
+ });
2097
+ res.end(JSON.stringify(media));
2098
+ } catch (error) {
2099
+ if (error instanceof PathTraversalError) {
2100
+ res.statusCode = 403;
2101
+ res.end(JSON.stringify({ error: error.message }));
2102
+ return;
2103
+ }
2104
+ throw error;
2105
+ }
2087
2106
  };
2088
2107
  const handleDelete = async (req, res) => {
2089
- const file = decodeURIComponent(req.url.slice("/media/".length));
2090
- const didDelete = await mediaModel.deleteMedia({ searchPath: file });
2091
- res.end(JSON.stringify(didDelete));
2108
+ try {
2109
+ const file = decodeURIComponent(req.url.slice("/media/".length));
2110
+ const didDelete = await mediaModel.deleteMedia({ searchPath: file });
2111
+ res.end(JSON.stringify(didDelete));
2112
+ } catch (error) {
2113
+ if (error instanceof PathTraversalError) {
2114
+ res.statusCode = 403;
2115
+ res.end(JSON.stringify({ error: error.message }));
2116
+ return;
2117
+ }
2118
+ throw error;
2119
+ }
2092
2120
  };
2093
2121
  const handlePost = async function(req, res) {
2094
2122
  const bb = busboy({ headers: req.headers });
2123
+ let responded = false;
2095
2124
  bb.on("file", async (_name, file, _info) => {
2096
- const fullPath = decodeURI(req.url?.slice("/media/upload/".length));
2097
- const saveTo = path6.join(mediaFolder, ...fullPath.split("/"));
2125
+ const fullPath = decodeURIComponent(
2126
+ req.url?.slice("/media/upload/".length)
2127
+ );
2128
+ let saveTo;
2129
+ try {
2130
+ saveTo = resolveStrictlyWithinBase(fullPath, mediaFolder);
2131
+ } catch {
2132
+ responded = true;
2133
+ file.resume();
2134
+ res.statusCode = 403;
2135
+ res.end(
2136
+ JSON.stringify({
2137
+ error: `Path traversal detected: ${fullPath}`
2138
+ })
2139
+ );
2140
+ return;
2141
+ }
2098
2142
  await fs5.ensureDir(path6.dirname(saveTo));
2099
2143
  file.pipe(fs5.createWriteStream(saveTo));
2100
2144
  });
2101
2145
  bb.on("error", (error) => {
2146
+ responded = true;
2102
2147
  res.statusCode = 500;
2103
2148
  if (error instanceof Error) {
2104
2149
  res.end(JSON.stringify({ message: error }));
@@ -2107,6 +2152,7 @@ var createMediaRouter = (config2) => {
2107
2152
  }
2108
2153
  });
2109
2154
  bb.on("close", () => {
2155
+ if (responded) return;
2110
2156
  res.statusCode = 200;
2111
2157
  res.end(JSON.stringify({ success: true }));
2112
2158
  });
@@ -2121,6 +2167,32 @@ var parseMediaFolder = (str) => {
2121
2167
  returnString = returnString.substr(0, returnString.length - 1);
2122
2168
  return returnString;
2123
2169
  };
2170
+ var ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
2171
+ function resolveWithinBase(userPath, baseDir) {
2172
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2173
+ throw new PathTraversalError(userPath);
2174
+ }
2175
+ const resolvedBase = path6.resolve(baseDir);
2176
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2177
+ if (resolved === resolvedBase) {
2178
+ return resolvedBase;
2179
+ }
2180
+ if (resolved.startsWith(resolvedBase + path6.sep)) {
2181
+ return resolved;
2182
+ }
2183
+ throw new PathTraversalError(userPath);
2184
+ }
2185
+ function resolveStrictlyWithinBase(userPath, baseDir) {
2186
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2187
+ throw new PathTraversalError(userPath);
2188
+ }
2189
+ const resolvedBase = path6.resolve(baseDir) + path6.sep;
2190
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2191
+ if (!resolved.startsWith(resolvedBase)) {
2192
+ throw new PathTraversalError(userPath);
2193
+ }
2194
+ return resolved;
2195
+ }
2124
2196
  var MediaModel = class {
2125
2197
  rootPath;
2126
2198
  publicFolder;
@@ -2132,22 +2204,18 @@ var MediaModel = class {
2132
2204
  }
2133
2205
  async listMedia(args) {
2134
2206
  try {
2135
- const folderPath = join(
2136
- this.rootPath,
2137
- this.publicFolder,
2138
- this.mediaRoot,
2139
- decodeURIComponent(args.searchPath)
2140
- );
2207
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2208
+ const validatedPath = resolveWithinBase(args.searchPath, mediaBase);
2141
2209
  const searchPath = parseMediaFolder(args.searchPath);
2142
- if (!await fs5.pathExists(folderPath)) {
2210
+ if (!await fs5.pathExists(validatedPath)) {
2143
2211
  return {
2144
2212
  files: [],
2145
2213
  directories: []
2146
2214
  };
2147
2215
  }
2148
- const filesStr = await fs5.readdir(folderPath);
2216
+ const filesStr = await fs5.readdir(validatedPath);
2149
2217
  const filesProm = filesStr.map(async (file) => {
2150
- const filePath = join(folderPath, file);
2218
+ const filePath = join(validatedPath, file);
2151
2219
  const stat = await fs5.stat(filePath);
2152
2220
  let src = `/${file}`;
2153
2221
  const isFile = stat.isFile();
@@ -2194,6 +2262,7 @@ var MediaModel = class {
2194
2262
  cursor
2195
2263
  };
2196
2264
  } catch (error) {
2265
+ if (error instanceof PathTraversalError) throw error;
2197
2266
  console.error(error);
2198
2267
  return {
2199
2268
  files: [],
@@ -2204,16 +2273,13 @@ var MediaModel = class {
2204
2273
  }
2205
2274
  async deleteMedia(args) {
2206
2275
  try {
2207
- const file = join(
2208
- this.rootPath,
2209
- this.publicFolder,
2210
- this.mediaRoot,
2211
- decodeURIComponent(args.searchPath)
2212
- );
2276
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2277
+ const file = resolveStrictlyWithinBase(args.searchPath, mediaBase);
2213
2278
  await fs5.stat(file);
2214
2279
  await fs5.remove(file);
2215
2280
  return { ok: true };
2216
2281
  } catch (error) {
2282
+ if (error instanceof PathTraversalError) throw error;
2217
2283
  console.error(error);
2218
2284
  return { ok: false, message: error?.toString() };
2219
2285
  }
@@ -1,5 +1,5 @@
1
- import type { Connect } from 'vite';
2
1
  import type { ServerResponse } from 'http';
2
+ import type { Connect } from 'vite';
3
3
  export declare const createMediaRouter: (config: PathConfig) => {
4
4
  handleList: (req: any, res: any) => Promise<void>;
5
5
  handleDelete: (req: Connect.IncomingMessage, res: any) => Promise<void>;
@@ -34,6 +34,23 @@ type SuccessRecord = {
34
34
  ok: false;
35
35
  message: string;
36
36
  };
37
+ /**
38
+ * Handles media file operations (list, delete) for the Vite-based dev server.
39
+ *
40
+ * @security Every method that accepts a user-supplied `searchPath` validates
41
+ * it against the media root using `resolveWithinBase` (list) or
42
+ * `resolveStrictlyWithinBase` (delete) before any filesystem access.
43
+ *
44
+ * - **list** uses `resolveWithinBase` because listing the media root itself
45
+ * (empty path / exact base match) is a valid operation.
46
+ * - **delete** uses `resolveStrictlyWithinBase` because deleting the media
47
+ * root directory itself must never be allowed.
48
+ *
49
+ * Both methods catch `PathTraversalError` and re-throw it so that the
50
+ * route handler can return a 403 response. Other errors are caught and
51
+ * returned as structured error responses (this avoids leaking stack traces
52
+ * to the client).
53
+ */
37
54
  export declare class MediaModel {
38
55
  readonly rootPath: string;
39
56
  readonly publicFolder: string;
@@ -28,6 +28,23 @@ type SuccessRecord = {
28
28
  ok: false;
29
29
  message: string;
30
30
  };
31
+ /**
32
+ * Handles media file operations (list, delete) for the Express-based server.
33
+ *
34
+ * @security Every method that accepts a user-supplied `searchPath` validates
35
+ * it against the media root using `resolveWithinBase` (list) or
36
+ * `resolveStrictlyWithinBase` (delete) before any filesystem access.
37
+ *
38
+ * - **list** uses `resolveWithinBase` because listing the media root itself
39
+ * (empty path / exact base match) is a valid operation.
40
+ * - **delete** uses `resolveStrictlyWithinBase` because deleting the media
41
+ * root directory itself must never be allowed.
42
+ *
43
+ * Both methods catch `PathTraversalError` and re-throw it so that the
44
+ * route handler can return a 403 response. Other errors are caught and
45
+ * returned as structured error responses (this avoids leaking stack traces
46
+ * to the client).
47
+ */
31
48
  export declare class MediaModel {
32
49
  readonly rootPath: string;
33
50
  readonly publicFolder: string;
@@ -1,3 +1,29 @@
1
1
  /** Removes trailing slash from path. Separator to remove is chosen based on
2
2
  * operating system. */
3
3
  export declare function stripNativeTrailingSlash(p: string): string;
4
+ /**
5
+ * Validates that a user-supplied path does not escape the base directory
6
+ * via path traversal (CWE-22). Returns the resolved absolute path.
7
+ *
8
+ * Allows an exact base match (empty or `.` input) — use this for list/read
9
+ * operations where referencing the root directory itself is valid. For
10
+ * delete/write operations where you need to target an actual file, use the
11
+ * `resolveStrictlyWithinBase` variant (currently inlined in media models).
12
+ *
13
+ * As a safety net, also rejects paths that still contain URL-encoded
14
+ * traversal sequences (`%2e%2e`, `%2f`, `%5c`), catching cases where the
15
+ * caller forgot to decode.
16
+ *
17
+ * @security This is the canonical implementation. Inlined copies exist in
18
+ * the media model files for CodeQL compatibility — keep them in sync.
19
+ *
20
+ * @param userPath - The untrusted path from the request (must already be
21
+ * URI-decoded by the caller).
22
+ * @param baseDir - The trusted base directory the path must stay within.
23
+ * @returns The resolved absolute path.
24
+ * @throws {PathTraversalError} If the path escapes the base directory.
25
+ */
26
+ export declare function assertPathWithinBase(userPath: string, baseDir: string): string;
27
+ export declare class PathTraversalError extends Error {
28
+ constructor(attemptedPath: string);
29
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tinacms/cli",
3
3
  "type": "module",
4
- "version": "2.1.6",
4
+ "version": "2.1.7",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "files": [
@@ -88,12 +88,12 @@
88
88
  "vite": "^4.5.9",
89
89
  "yup": "^1.6.1",
90
90
  "zod": "^3.24.2",
91
- "@tinacms/app": "2.3.25",
92
- "@tinacms/schema-tools": "2.6.0",
93
- "@tinacms/graphql": "2.1.2",
91
+ "@tinacms/app": "2.3.26",
92
+ "@tinacms/graphql": "2.1.3",
94
93
  "@tinacms/metrics": "2.0.1",
95
- "@tinacms/search": "1.2.3",
96
- "tinacms": "3.5.0"
94
+ "@tinacms/schema-tools": "2.6.0",
95
+ "@tinacms/search": "1.2.4",
96
+ "tinacms": "3.5.1"
97
97
  },
98
98
  "publishConfig": {
99
99
  "registry": "https://registry.npmjs.org"