@tinacms/cli 2.1.6 → 2.1.8

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.8";
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";
@@ -1247,15 +1255,15 @@ var loaders = {
1247
1255
  };
1248
1256
 
1249
1257
  // src/next/database.ts
1258
+ import { createServer } from "net";
1250
1259
  import {
1251
- createDatabaseInternal,
1252
1260
  FilesystemBridge,
1253
- TinaLevelClient
1261
+ TinaLevelClient,
1262
+ createDatabaseInternal
1254
1263
  } from "@tinacms/graphql";
1255
- import { pipeline } from "readable-stream";
1256
- import { createServer } from "net";
1257
1264
  import { ManyLevelHost } from "many-level";
1258
1265
  import { MemoryLevel } from "memory-level";
1266
+ import { pipeline } from "readable-stream";
1259
1267
  var createDBServer = (port) => {
1260
1268
  const levelHost = new ManyLevelHost(
1261
1269
  // @ts-ignore
@@ -1274,7 +1282,7 @@ var createDBServer = (port) => {
1274
1282
  );
1275
1283
  }
1276
1284
  });
1277
- dbServer.listen(port);
1285
+ dbServer.listen(port, "localhost");
1278
1286
  };
1279
1287
  async function createAndInitializeDatabase(configManager, datalayerPort, bridgeOverride) {
1280
1288
  let database;
@@ -1578,6 +1586,43 @@ import {
1578
1586
  splitVendorChunkPlugin
1579
1587
  } from "vite";
1580
1588
 
1589
+ // src/next/vite/cors.ts
1590
+ var LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
1591
+ var PRIVATE_NETWORK_RE = /^https?:\/\/(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})(:\d+)?$/;
1592
+ function expandOrigins(raw) {
1593
+ const hasPrivate = raw.some((o) => o === "private");
1594
+ const filtered = raw.filter((o) => o !== "private");
1595
+ return hasPrivate ? [...filtered, PRIVATE_NETWORK_RE] : filtered;
1596
+ }
1597
+ function buildCorsOriginCheck(allowedOrigins = []) {
1598
+ const extra = expandOrigins(allowedOrigins);
1599
+ return (origin, callback) => {
1600
+ if (!origin) {
1601
+ callback(null, true);
1602
+ return;
1603
+ }
1604
+ if (LOCALHOST_RE.test(origin)) {
1605
+ callback(null, true);
1606
+ return;
1607
+ }
1608
+ for (const allowed of extra) {
1609
+ if (typeof allowed === "string") {
1610
+ if (allowed === origin) {
1611
+ callback(null, true);
1612
+ return;
1613
+ }
1614
+ } else {
1615
+ allowed.lastIndex = 0;
1616
+ if (allowed.test(origin)) {
1617
+ callback(null, true);
1618
+ return;
1619
+ }
1620
+ }
1621
+ }
1622
+ callback(null, false);
1623
+ };
1624
+ }
1625
+
1581
1626
  // src/next/vite/filterPublicEnv.ts
1582
1627
  function filterPublicEnv(env = process.env) {
1583
1628
  const publicEnv = {};
@@ -2014,6 +2059,13 @@ var createConfig = async ({
2014
2059
  },
2015
2060
  server: {
2016
2061
  host: configManager.config?.build?.host ?? false,
2062
+ // Restrict Vite's built-in CORS to the same origins our custom
2063
+ // middleware allows (localhost + user-configured allowedOrigins).
2064
+ cors: {
2065
+ origin: buildCorsOriginCheck(
2066
+ configManager.config?.server?.allowedOrigins
2067
+ )
2068
+ },
2017
2069
  watch: noWatch ? {
2018
2070
  ignored: ["**/*"]
2019
2071
  } : {
@@ -2023,7 +2075,15 @@ var createConfig = async ({
2023
2075
  ]
2024
2076
  },
2025
2077
  fs: {
2026
- strict: false
2078
+ strict: true,
2079
+ // Allow serving files from the project root and the SPA package.
2080
+ // Without this, Vite would block access to tina config/generated
2081
+ // files since the Vite root is the @tinacms/app package directory.
2082
+ allow: [
2083
+ configManager.spaRootPath,
2084
+ configManager.rootPath,
2085
+ ...configManager.contentRootPath && configManager.contentRootPath !== configManager.rootPath ? [configManager.contentRootPath] : []
2086
+ ]
2027
2087
  }
2028
2088
  },
2029
2089
  build: {
@@ -2053,19 +2113,19 @@ var createConfig = async ({
2053
2113
  };
2054
2114
 
2055
2115
  // src/next/vite/plugins.ts
2056
- import { createFilter } from "@rollup/pluginutils";
2057
2116
  import fs6 from "fs";
2058
- import { transformWithEsbuild } from "vite";
2059
- import { transform as esbuildTransform } from "esbuild";
2060
2117
  import path7 from "path";
2118
+ import { createFilter } from "@rollup/pluginutils";
2119
+ import { resolve as gqlResolve } from "@tinacms/graphql";
2061
2120
  import bodyParser from "body-parser";
2062
2121
  import cors from "cors";
2063
- import { resolve as gqlResolve } from "@tinacms/graphql";
2122
+ import { transform as esbuildTransform } from "esbuild";
2123
+ import { transformWithEsbuild } from "vite";
2064
2124
 
2065
2125
  // src/next/commands/dev-command/server/media.ts
2066
- import fs5 from "fs-extra";
2067
2126
  import path6, { join } from "path";
2068
2127
  import busboy from "busboy";
2128
+ import fs5 from "fs-extra";
2069
2129
  var createMediaRouter = (config2) => {
2070
2130
  const mediaFolder = path6.join(
2071
2131
  config2.rootPath,
@@ -2074,31 +2134,68 @@ var createMediaRouter = (config2) => {
2074
2134
  );
2075
2135
  const mediaModel = new MediaModel(config2);
2076
2136
  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));
2137
+ try {
2138
+ const requestURL = new URL(req.url, config2.apiURL);
2139
+ const folder = decodeURIComponent(
2140
+ requestURL.pathname.replace("/media/list/", "")
2141
+ );
2142
+ const limit = requestURL.searchParams.get("limit");
2143
+ const cursor = requestURL.searchParams.get("cursor");
2144
+ const media = await mediaModel.listMedia({
2145
+ searchPath: folder,
2146
+ cursor,
2147
+ limit
2148
+ });
2149
+ res.end(JSON.stringify(media));
2150
+ } catch (error) {
2151
+ if (error instanceof PathTraversalError) {
2152
+ res.statusCode = 403;
2153
+ res.end(JSON.stringify({ error: error.message }));
2154
+ return;
2155
+ }
2156
+ throw error;
2157
+ }
2087
2158
  };
2088
2159
  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));
2160
+ try {
2161
+ const file = decodeURIComponent(req.url.slice("/media/".length));
2162
+ const didDelete = await mediaModel.deleteMedia({ searchPath: file });
2163
+ res.end(JSON.stringify(didDelete));
2164
+ } catch (error) {
2165
+ if (error instanceof PathTraversalError) {
2166
+ res.statusCode = 403;
2167
+ res.end(JSON.stringify({ error: error.message }));
2168
+ return;
2169
+ }
2170
+ throw error;
2171
+ }
2092
2172
  };
2093
2173
  const handlePost = async function(req, res) {
2094
2174
  const bb = busboy({ headers: req.headers });
2175
+ let responded = false;
2095
2176
  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("/"));
2177
+ const fullPath = decodeURIComponent(
2178
+ req.url?.slice("/media/upload/".length)
2179
+ );
2180
+ let saveTo;
2181
+ try {
2182
+ saveTo = resolveStrictlyWithinBase(fullPath, mediaFolder);
2183
+ } catch {
2184
+ responded = true;
2185
+ file.resume();
2186
+ res.statusCode = 403;
2187
+ res.end(
2188
+ JSON.stringify({
2189
+ error: `Path traversal detected: ${fullPath}`
2190
+ })
2191
+ );
2192
+ return;
2193
+ }
2098
2194
  await fs5.ensureDir(path6.dirname(saveTo));
2099
2195
  file.pipe(fs5.createWriteStream(saveTo));
2100
2196
  });
2101
2197
  bb.on("error", (error) => {
2198
+ responded = true;
2102
2199
  res.statusCode = 500;
2103
2200
  if (error instanceof Error) {
2104
2201
  res.end(JSON.stringify({ message: error }));
@@ -2107,6 +2204,7 @@ var createMediaRouter = (config2) => {
2107
2204
  }
2108
2205
  });
2109
2206
  bb.on("close", () => {
2207
+ if (responded) return;
2110
2208
  res.statusCode = 200;
2111
2209
  res.end(JSON.stringify({ success: true }));
2112
2210
  });
@@ -2121,6 +2219,32 @@ var parseMediaFolder = (str) => {
2121
2219
  returnString = returnString.substr(0, returnString.length - 1);
2122
2220
  return returnString;
2123
2221
  };
2222
+ var ENCODED_TRAVERSAL_RE = /%2e%2e|%2f|%5c/i;
2223
+ function resolveWithinBase(userPath, baseDir) {
2224
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2225
+ throw new PathTraversalError(userPath);
2226
+ }
2227
+ const resolvedBase = path6.resolve(baseDir);
2228
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2229
+ if (resolved === resolvedBase) {
2230
+ return resolvedBase;
2231
+ }
2232
+ if (resolved.startsWith(resolvedBase + path6.sep)) {
2233
+ return resolved;
2234
+ }
2235
+ throw new PathTraversalError(userPath);
2236
+ }
2237
+ function resolveStrictlyWithinBase(userPath, baseDir) {
2238
+ if (ENCODED_TRAVERSAL_RE.test(userPath)) {
2239
+ throw new PathTraversalError(userPath);
2240
+ }
2241
+ const resolvedBase = path6.resolve(baseDir) + path6.sep;
2242
+ const resolved = path6.resolve(path6.join(baseDir, userPath));
2243
+ if (!resolved.startsWith(resolvedBase)) {
2244
+ throw new PathTraversalError(userPath);
2245
+ }
2246
+ return resolved;
2247
+ }
2124
2248
  var MediaModel = class {
2125
2249
  rootPath;
2126
2250
  publicFolder;
@@ -2132,22 +2256,18 @@ var MediaModel = class {
2132
2256
  }
2133
2257
  async listMedia(args) {
2134
2258
  try {
2135
- const folderPath = join(
2136
- this.rootPath,
2137
- this.publicFolder,
2138
- this.mediaRoot,
2139
- decodeURIComponent(args.searchPath)
2140
- );
2259
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2260
+ const validatedPath = resolveWithinBase(args.searchPath, mediaBase);
2141
2261
  const searchPath = parseMediaFolder(args.searchPath);
2142
- if (!await fs5.pathExists(folderPath)) {
2262
+ if (!await fs5.pathExists(validatedPath)) {
2143
2263
  return {
2144
2264
  files: [],
2145
2265
  directories: []
2146
2266
  };
2147
2267
  }
2148
- const filesStr = await fs5.readdir(folderPath);
2268
+ const filesStr = await fs5.readdir(validatedPath);
2149
2269
  const filesProm = filesStr.map(async (file) => {
2150
- const filePath = join(folderPath, file);
2270
+ const filePath = join(validatedPath, file);
2151
2271
  const stat = await fs5.stat(filePath);
2152
2272
  let src = `/${file}`;
2153
2273
  const isFile = stat.isFile();
@@ -2194,6 +2314,7 @@ var MediaModel = class {
2194
2314
  cursor
2195
2315
  };
2196
2316
  } catch (error) {
2317
+ if (error instanceof PathTraversalError) throw error;
2197
2318
  console.error(error);
2198
2319
  return {
2199
2320
  files: [],
@@ -2204,16 +2325,13 @@ var MediaModel = class {
2204
2325
  }
2205
2326
  async deleteMedia(args) {
2206
2327
  try {
2207
- const file = join(
2208
- this.rootPath,
2209
- this.publicFolder,
2210
- this.mediaRoot,
2211
- decodeURIComponent(args.searchPath)
2212
- );
2328
+ const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
2329
+ const file = resolveStrictlyWithinBase(args.searchPath, mediaBase);
2213
2330
  await fs5.stat(file);
2214
2331
  await fs5.remove(file);
2215
2332
  return { ok: true };
2216
2333
  } catch (error) {
2334
+ if (error instanceof PathTraversalError) throw error;
2217
2335
  console.error(error);
2218
2336
  return { ok: false, message: error?.toString() };
2219
2337
  }
@@ -2336,10 +2454,18 @@ var devServerEndPointsPlugin = ({
2336
2454
  searchIndex,
2337
2455
  databaseLock
2338
2456
  }) => {
2457
+ const corsOriginCheck = buildCorsOriginCheck(
2458
+ configManager.config?.server?.allowedOrigins
2459
+ );
2339
2460
  const plug = {
2340
2461
  name: "graphql-endpoints",
2341
2462
  configureServer(server) {
2342
- server.middlewares.use(cors());
2463
+ server.middlewares.use(
2464
+ cors({
2465
+ origin: corsOriginCheck,
2466
+ methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
2467
+ })
2468
+ );
2343
2469
  server.middlewares.use(bodyParser.json({ limit: "5mb" }));
2344
2470
  server.middlewares.use(async (req, res, next) => {
2345
2471
  const mediaPaths = configManager.config.media?.tina;
@@ -5806,6 +5932,12 @@ import { LocalAuthProvider } from "tinacms";`;
5806
5932
  outputFolder: "admin",
5807
5933
  publicFolder: "${args.publicFolder}",
5808
5934
  },
5935
+ // Uncomment to allow cross-origin requests from non-localhost origins
5936
+ // during local development (e.g. GitHub Codespaces, Gitpod, Docker).
5937
+ // Use 'private' to allow all private-network IPs (WSL2, Docker, etc.)
5938
+ // server: {
5939
+ // allowedOrigins: ['https://your-codespace.github.dev'],
5940
+ // },
5809
5941
  media: {
5810
5942
  tina: {
5811
5943
  mediaRoot: "",
@@ -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;
@@ -1,4 +1,4 @@
1
- import { Database, Bridge } from '@tinacms/graphql';
1
+ import { Bridge, Database } from '@tinacms/graphql';
2
2
  import { ConfigManager } from './config-manager';
3
3
  export declare const createDBServer: (port: number) => void;
4
4
  export declare function createAndInitializeDatabase(configManager: ConfigManager, datalayerPort: number, bridgeOverride?: Bridge): Promise<Database>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared CORS origin-checking logic for the TinaCMS dev server.
3
+ *
4
+ * By default only localhost / 127.0.0.1 / [::1] (any port) are allowed.
5
+ * Users can extend this via `server.allowedOrigins` in their tina config.
6
+ * The special keyword `'private'` expands to all RFC 1918 private-network
7
+ * IP ranges (10.x, 172.16-31.x, 192.168.x) — useful for WSL2, Docker
8
+ * bridge networks, etc.
9
+ */
10
+ /**
11
+ * Build a CORS `origin` callback compatible with the `cors` npm package.
12
+ */
13
+ export declare function buildCorsOriginCheck(allowedOrigins?: (string | RegExp)[]): (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => void;
@@ -1,8 +1,8 @@
1
- import type { Plugin } from 'vite';
2
1
  import { FilterPattern } from '@rollup/pluginutils';
3
2
  import type { Config } from '@svgr/core';
4
- import { transformWithEsbuild } from 'vite';
5
3
  import type { Database } from '@tinacms/graphql';
4
+ import type { Plugin } from 'vite';
5
+ import { transformWithEsbuild } from 'vite';
6
6
  import type { ConfigManager } from '../config-manager';
7
7
  export declare const transformTsxPlugin: ({ configManager: _configManager, }: {
8
8
  configManager: ConfigManager;
@@ -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.8",
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.27",
92
+ "@tinacms/graphql": "2.1.4",
94
93
  "@tinacms/metrics": "2.0.1",
95
- "@tinacms/search": "1.2.3",
96
- "tinacms": "3.5.0"
94
+ "@tinacms/schema-tools": "2.7.0",
95
+ "@tinacms/search": "1.2.5",
96
+ "tinacms": "3.6.0"
97
97
  },
98
98
  "publishConfig": {
99
99
  "registry": "https://registry.npmjs.org"