@tinacms/graphql 2.1.1 → 2.1.3

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.
@@ -3,6 +3,10 @@ import type { Bridge } from './index';
3
3
  * This is the bridge from whatever datasource we need for I/O.
4
4
  * The basic example here is for the filesystem, one is needed
5
5
  * for GitHub has well.
6
+ *
7
+ * @security All public methods validate their `filepath` / `pattern`
8
+ * argument via `assertWithinBase` before performing any I/O. If you add a
9
+ * new method that accepts a path, you MUST validate it the same way.
6
10
  */
7
11
  export declare class FilesystemBridge implements Bridge {
8
12
  rootPath: string;
@@ -1,8 +1,36 @@
1
+ /**
2
+ * I/O abstraction layer for reading/writing content files.
3
+ *
4
+ * @security **Path traversal (CWE-22):** All `filepath` and `pattern`
5
+ * parameters may originate from user input (e.g. GraphQL mutations or media
6
+ * API requests). Implementations MUST validate that resolved paths stay
7
+ * within their root/output directory before performing any filesystem
8
+ * operation. See `FilesystemBridge.assertWithinBase` and
9
+ * `IsomorphicBridge.assertWithinBase` for reference implementations.
10
+ *
11
+ * The recommended validation pattern is:
12
+ * ```ts
13
+ * const resolved = path.resolve(path.join(baseDir, filepath));
14
+ * if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) {
15
+ * throw new Error('Path traversal detected');
16
+ * }
17
+ * ```
18
+ *
19
+ * If you are adding a new Bridge implementation, add path traversal
20
+ * validation to every method that accepts a filepath from the caller.
21
+ */
1
22
  export interface Bridge {
2
23
  rootPath: string;
24
+ /**
25
+ * @param pattern - Glob pattern prefix (untrusted — validate before use).
26
+ * @param extension - File extension to match.
27
+ */
3
28
  glob(pattern: string, extension: string): Promise<string[]>;
29
+ /** @param filepath - Relative path to delete (untrusted — validate before use). */
4
30
  delete(filepath: string): Promise<void>;
31
+ /** @param filepath - Relative path to read (untrusted — validate before use). */
5
32
  get(filepath: string): Promise<string>;
33
+ /** @param filepath - Relative path to write (untrusted — validate before use). */
6
34
  put(filepath: string, data: string): Promise<void>;
7
35
  /**
8
36
  * Optionally, the bridge can perform
@@ -18,6 +18,11 @@ export type IsomorphicGitBridgeOptions = {
18
18
  };
19
19
  /**
20
20
  * Bridge backed by isomorphic-git
21
+ *
22
+ * @security All public methods (glob, get, put, delete) validate their
23
+ * `filepath` / `pattern` argument via `assertWithinBase` before performing
24
+ * any git operations. If you add a new method that accepts a path, you
25
+ * MUST validate it the same way.
21
26
  */
22
27
  export declare class IsomorphicBridge implements Bridge {
23
28
  rootPath: string;
package/dist/index.js CHANGED
@@ -45,8 +45,8 @@ var btoa = (string2) => {
45
45
  var lastItem = (arr) => {
46
46
  return arr[arr.length - 1];
47
47
  };
48
- var get = (obj, path8, defaultValue = void 0) => {
49
- const travel = (regexp) => String.prototype.split.call(path8, regexp).filter(Boolean).reduce(
48
+ var get = (obj, path9, defaultValue = void 0) => {
49
+ const travel = (regexp) => String.prototype.split.call(path9, regexp).filter(Boolean).reduce(
50
50
  (res, key) => res !== null && res !== void 0 ? res[key] : res,
51
51
  obj
52
52
  );
@@ -3026,7 +3026,7 @@ var validateField = async (field) => {
3026
3026
  var package_default = {
3027
3027
  name: "@tinacms/graphql",
3028
3028
  type: "module",
3029
- version: "2.1.1",
3029
+ version: "2.1.3",
3030
3030
  main: "dist/index.js",
3031
3031
  module: "./dist/index.js",
3032
3032
  files: [
@@ -3662,17 +3662,17 @@ var scanAllContent = async (tinaSchema, bridge, callback) => {
3662
3662
  const documentPaths = await bridge.glob(normalPath, format);
3663
3663
  const matches = tinaSchema.getMatches({ collection });
3664
3664
  const filteredPaths = matches.length > 0 ? micromatch(documentPaths, matches) : documentPaths;
3665
- filteredPaths.forEach((path8) => {
3666
- if (filesSeen.has(path8)) {
3667
- filesSeen.get(path8).push(collection.name);
3668
- duplicateFiles.add(path8);
3665
+ filteredPaths.forEach((path9) => {
3666
+ if (filesSeen.has(path9)) {
3667
+ filesSeen.get(path9).push(collection.name);
3668
+ duplicateFiles.add(path9);
3669
3669
  } else {
3670
- filesSeen.set(path8, [collection.name]);
3670
+ filesSeen.set(path9, [collection.name]);
3671
3671
  }
3672
3672
  });
3673
- duplicateFiles.forEach((path8) => {
3673
+ duplicateFiles.forEach((path9) => {
3674
3674
  warnings.push(
3675
- `"${path8}" Found in multiple collections: ${filesSeen.get(path8).map((collection2) => `"${collection2}"`).join(
3675
+ `"${path9}" Found in multiple collections: ${filesSeen.get(path9).map((collection2) => `"${collection2}"`).join(
3676
3676
  ", "
3677
3677
  )}. This can cause unexpected behavior. We recommend updating the \`match\` property of those collections so that each file is in only one collection.
3678
3678
  This will be an error in the future. See https://tina.io/docs/errors/file-in-mutpliple-collections/
@@ -4235,9 +4235,9 @@ var makeFilterSuffixes = (filterChain, index) => {
4235
4235
  }
4236
4236
  };
4237
4237
  var FOLDER_ROOT = "~";
4238
- var stripCollectionFromPath = (collectionPath, path8) => {
4238
+ var stripCollectionFromPath = (collectionPath, path9) => {
4239
4239
  const collectionPathParts = collectionPath.split("/");
4240
- const pathParts = path8.split("/");
4240
+ const pathParts = path9.split("/");
4241
4241
  const strippedPathParts = pathParts.slice(collectionPathParts.length);
4242
4242
  return strippedPathParts.join("/");
4243
4243
  };
@@ -4293,13 +4293,13 @@ var makeFolderOpsForCollection = (folderTree, collection, indexDefinitions, opTy
4293
4293
  SUBLEVEL_OPTIONS
4294
4294
  );
4295
4295
  let folderSortingIdx = 0;
4296
- for (const path8 of Array.from(folder).sort()) {
4296
+ for (const path9 of Array.from(folder).sort()) {
4297
4297
  for (const [sort] of Object.entries(indexDefinitions)) {
4298
4298
  const indexSublevel = folderCollectionSublevel.sublevel(
4299
4299
  sort,
4300
4300
  SUBLEVEL_OPTIONS
4301
4301
  );
4302
- const subFolderKey = sha.hex(path8);
4302
+ const subFolderKey = sha.hex(path9);
4303
4303
  if (sort === DEFAULT_COLLECTION_SORT_KEY) {
4304
4304
  result.push({
4305
4305
  type: opType,
@@ -4381,8 +4381,8 @@ var makeRefOpsForDocument = (filepath, collection, references, data, opType, lev
4381
4381
  SUBLEVEL_OPTIONS
4382
4382
  );
4383
4383
  const references2 = {};
4384
- for (const path8 of referencePaths) {
4385
- const ref = JSONPath({ path: path8, json: data });
4384
+ for (const path9 of referencePaths) {
4385
+ const ref = JSONPath({ path: path9, json: data });
4386
4386
  if (!ref) {
4387
4387
  continue;
4388
4388
  }
@@ -4392,24 +4392,24 @@ var makeRefOpsForDocument = (filepath, collection, references, data, opType, lev
4392
4392
  continue;
4393
4393
  }
4394
4394
  if (references2[r]) {
4395
- references2[r].push(path8);
4395
+ references2[r].push(path9);
4396
4396
  } else {
4397
- references2[r] = [path8];
4397
+ references2[r] = [path9];
4398
4398
  }
4399
4399
  }
4400
4400
  } else {
4401
4401
  if (references2[ref]) {
4402
- references2[ref].push(path8);
4402
+ references2[ref].push(path9);
4403
4403
  } else {
4404
- references2[ref] = [path8];
4404
+ references2[ref] = [path9];
4405
4405
  }
4406
4406
  }
4407
4407
  }
4408
4408
  for (const ref of Object.keys(references2)) {
4409
- for (const path8 of references2[ref]) {
4409
+ for (const path9 of references2[ref]) {
4410
4410
  result.push({
4411
4411
  type: opType,
4412
- key: `${ref}${INDEX_KEY_FIELD_SEPARATOR}${path8}${INDEX_KEY_FIELD_SEPARATOR}${filepath}`,
4412
+ key: `${ref}${INDEX_KEY_FIELD_SEPARATOR}${path9}${INDEX_KEY_FIELD_SEPARATOR}${filepath}`,
4413
4413
  sublevel: refSublevel,
4414
4414
  value: opType === "put" ? {} : void 0
4415
4415
  });
@@ -4676,9 +4676,9 @@ var resolveMediaRelativeToCloud = (value, config = { useRelativeMedia: true }, s
4676
4676
  return value;
4677
4677
  }
4678
4678
  };
4679
- var cleanUpSlashes = (path8) => {
4680
- if (path8) {
4681
- return `/${path8.replace(/^\/+|\/+$/gm, "")}`;
4679
+ var cleanUpSlashes = (path9) => {
4680
+ if (path9) {
4681
+ return `/${path9.replace(/^\/+|\/+$/gm, "")}`;
4682
4682
  }
4683
4683
  return "";
4684
4684
  };
@@ -4888,17 +4888,17 @@ var transformDocumentIntoPayload = async (fullPath, rawData, tinaSchema, config,
4888
4888
  throw e;
4889
4889
  }
4890
4890
  };
4891
- var updateObjectWithJsonPath = (obj, path8, oldValue, newValue) => {
4891
+ var updateObjectWithJsonPath = (obj, path9, oldValue, newValue) => {
4892
4892
  let updated = false;
4893
- if (!path8.includes(".") && !path8.includes("[")) {
4894
- if (path8 in obj && obj[path8] === oldValue) {
4895
- obj[path8] = newValue;
4893
+ if (!path9.includes(".") && !path9.includes("[")) {
4894
+ if (path9 in obj && obj[path9] === oldValue) {
4895
+ obj[path9] = newValue;
4896
4896
  updated = true;
4897
4897
  }
4898
4898
  return { object: obj, updated };
4899
4899
  }
4900
- const parentPath = path8.replace(/\.[^.\[\]]+$/, "");
4901
- const keyToUpdate = path8.match(/[^.\[\]]+$/)[0];
4900
+ const parentPath = path9.replace(/\.[^.\[\]]+$/, "");
4901
+ const keyToUpdate = path9.match(/[^.\[\]]+$/)[0];
4902
4902
  const parents = JSONPath2({
4903
4903
  path: parentPath,
4904
4904
  json: obj,
@@ -5100,8 +5100,10 @@ var Resolver = class {
5100
5100
  relativePath,
5101
5101
  templateName
5102
5102
  }) => {
5103
- const collection = this.getCollectionWithName(collectionName);
5104
- const realPath = path3.join(collection.path, relativePath);
5103
+ const { collection, realPath } = this.getValidatedPath(
5104
+ collectionName,
5105
+ relativePath
5106
+ );
5105
5107
  const alreadyExists = await this.database.documentExists(realPath);
5106
5108
  if (alreadyExists) {
5107
5109
  throw new Error(`Unable to add document, ${realPath} already exists`);
@@ -5178,6 +5180,58 @@ var Resolver = class {
5178
5180
  }
5179
5181
  return this.tinaSchema.getCollection(collectionName);
5180
5182
  };
5183
+ /**
5184
+ * validatePath ensures that the provided path remains within the boundaries
5185
+ * of the collection's directory and that the file extension matches the
5186
+ * collection's configured format. This is a critical security check to prevent
5187
+ * path traversal attacks where a user might attempt to read or write files
5188
+ * outside of the intended collection.
5189
+ */
5190
+ validatePath = (fullPath, collection, relativePath) => {
5191
+ const normalizedPath = path3.normalize(fullPath);
5192
+ const normalizedCollectionPath = path3.normalize(collection.path);
5193
+ const relative = path3.relative(normalizedCollectionPath, normalizedPath);
5194
+ if (relative.startsWith("..")) {
5195
+ throw new Error(`Invalid path: path escapes the collection directory`);
5196
+ }
5197
+ if (path3.isAbsolute(relative)) {
5198
+ throw new Error(`Invalid path: absolute paths are not allowed`);
5199
+ }
5200
+ if (relativePath) {
5201
+ const collectionFormat = collection.format || "md";
5202
+ const fileExtension = path3.extname(relativePath).toLowerCase().slice(1);
5203
+ if (fileExtension !== collectionFormat) {
5204
+ throw new Error(
5205
+ `Invalid file extension: expected '.${collectionFormat}' but got '.${fileExtension}'`
5206
+ );
5207
+ }
5208
+ }
5209
+ };
5210
+ /**
5211
+ * Helper method to get collection and construct validated path.
5212
+ * This encapsulates the common pattern of getting a collection, joining paths,
5213
+ * and validating the result, ensuring security checks are always performed.
5214
+ *
5215
+ * @param collectionName - Name of the collection
5216
+ * @param relativePath - Relative path within the collection
5217
+ * @param options - Optional configuration
5218
+ * @returns Object containing the collection and validated real path
5219
+ */
5220
+ getValidatedPath = (collectionName, relativePath, options) => {
5221
+ const collection = this.getCollectionWithName(collectionName);
5222
+ const pathSegments = [collection.path, relativePath];
5223
+ if (options?.extraSegments) {
5224
+ pathSegments.push(...options.extraSegments);
5225
+ }
5226
+ const realPath = path3.join(...pathSegments);
5227
+ const shouldValidateExtension = options?.validateExtension !== false;
5228
+ this.validatePath(
5229
+ realPath,
5230
+ collection,
5231
+ shouldValidateExtension ? relativePath : void 0
5232
+ );
5233
+ return { collection, realPath };
5234
+ };
5181
5235
  /*
5182
5236
  * Used for getDocument, get<Collection>Document.
5183
5237
  */
@@ -5185,8 +5239,10 @@ var Resolver = class {
5185
5239
  collectionName,
5186
5240
  relativePath
5187
5241
  }) => {
5188
- const collection = this.getCollectionWithName(collectionName);
5189
- const realPath = path3.join(collection.path, relativePath);
5242
+ const { collection, realPath } = this.getValidatedPath(
5243
+ collectionName,
5244
+ relativePath
5245
+ );
5190
5246
  return this.getDocument(realPath, {
5191
5247
  collection,
5192
5248
  checkReferences: true
@@ -5205,6 +5261,7 @@ var Resolver = class {
5205
5261
  relativePath,
5206
5262
  `.gitkeep.${collection.format || "md"}`
5207
5263
  );
5264
+ this.validatePath(realPath, collection);
5208
5265
  const alreadyExists = await this.database.documentExists(realPath);
5209
5266
  if (alreadyExists) {
5210
5267
  throw new Error(`Unable to add folder, ${realPath} already exists`);
@@ -5221,8 +5278,10 @@ var Resolver = class {
5221
5278
  relativePath,
5222
5279
  body
5223
5280
  }) => {
5224
- const collection = this.getCollectionWithName(collectionName);
5225
- const realPath = path3.join(collection.path, relativePath);
5281
+ const { collection, realPath } = this.getValidatedPath(
5282
+ collectionName,
5283
+ relativePath
5284
+ );
5226
5285
  const alreadyExists = await this.database.documentExists(realPath);
5227
5286
  if (alreadyExists) {
5228
5287
  throw new Error(`Unable to add document, ${realPath} already exists`);
@@ -5237,15 +5296,20 @@ var Resolver = class {
5237
5296
  newRelativePath,
5238
5297
  newBody
5239
5298
  }) => {
5240
- const collection = this.getCollectionWithName(collectionName);
5241
- const realPath = path3.join(collection.path, relativePath);
5299
+ const { collection, realPath } = this.getValidatedPath(
5300
+ collectionName,
5301
+ relativePath
5302
+ );
5242
5303
  const alreadyExists = await this.database.documentExists(realPath);
5243
5304
  if (!alreadyExists) {
5244
5305
  throw new Error(`Unable to update document, ${realPath} does not exist`);
5245
5306
  }
5246
5307
  const doc = await this.getDocument(realPath);
5247
5308
  if (newRelativePath) {
5248
- const newRealPath = path3.join(collection?.path, newRelativePath);
5309
+ const { realPath: newRealPath } = this.getValidatedPath(
5310
+ collectionName,
5311
+ newRelativePath
5312
+ );
5249
5313
  if (newRealPath === realPath) {
5250
5314
  return doc;
5251
5315
  }
@@ -5258,10 +5322,10 @@ var Resolver = class {
5258
5322
  )) {
5259
5323
  let docWithRef = await this.getRaw(pathToDocWithRef);
5260
5324
  let hasUpdate = false;
5261
- for (const path8 of referencePaths) {
5325
+ for (const path9 of referencePaths) {
5262
5326
  const { object: object2, updated } = updateObjectWithJsonPath(
5263
5327
  docWithRef,
5264
- path8,
5328
+ path9,
5265
5329
  realPath,
5266
5330
  newRealPath
5267
5331
  );
@@ -5308,8 +5372,10 @@ var Resolver = class {
5308
5372
  collectionName,
5309
5373
  relativePath
5310
5374
  }) => {
5311
- const collection = this.getCollectionWithName(collectionName);
5312
- const realPath = path3.join(collection.path, relativePath);
5375
+ const { collection, realPath } = this.getValidatedPath(
5376
+ collectionName,
5377
+ relativePath
5378
+ );
5313
5379
  const alreadyExists = await this.database.documentExists(realPath);
5314
5380
  if (!alreadyExists) {
5315
5381
  throw new Error(`Unable to delete document, ${realPath} does not exist`);
@@ -5324,10 +5390,10 @@ var Resolver = class {
5324
5390
  )) {
5325
5391
  let refDoc = await this.getRaw(pathToDocWithRef);
5326
5392
  let hasUpdate = false;
5327
- for (const path8 of referencePaths) {
5393
+ for (const path9 of referencePaths) {
5328
5394
  const { object: object2, updated } = updateObjectWithJsonPath(
5329
5395
  refDoc,
5330
- path8,
5396
+ path9,
5331
5397
  realPath,
5332
5398
  null
5333
5399
  );
@@ -5528,7 +5594,10 @@ var Resolver = class {
5528
5594
  params: yup3.object({ relativePath: yup3.string().required() }).required()
5529
5595
  })
5530
5596
  );
5531
- const realPath = path3.join(collection.path, args.relativePath);
5597
+ const realPath = this.getValidatedPath(
5598
+ collection.name,
5599
+ args.relativePath
5600
+ ).realPath;
5532
5601
  return this.updateResolveDocument({
5533
5602
  collection,
5534
5603
  realPath,
@@ -5548,7 +5617,10 @@ var Resolver = class {
5548
5617
  });
5549
5618
  }
5550
5619
  } else {
5551
- const realPath = path3.join(collection.path, args.relativePath);
5620
+ const realPath = this.getValidatedPath(
5621
+ collection.name,
5622
+ args.relativePath
5623
+ ).realPath;
5552
5624
  return this.getDocument(realPath, {
5553
5625
  collection,
5554
5626
  checkReferences: true
@@ -5588,7 +5660,7 @@ var Resolver = class {
5588
5660
  first: -1
5589
5661
  },
5590
5662
  collection: referencedCollection,
5591
- hydrator: (path8) => path8
5663
+ hydrator: (path9) => path9
5592
5664
  // just return the path
5593
5665
  }
5594
5666
  );
@@ -6029,8 +6101,8 @@ async function handleUpdatePassword({
6029
6101
  // src/error.ts
6030
6102
  import { GraphQLError as GraphQLError3 } from "graphql";
6031
6103
  var NotFoundError = class extends GraphQLError3 {
6032
- constructor(message, nodes, source, positions, path8, originalError, extensions) {
6033
- super(message, nodes, source, positions, path8, originalError, extensions);
6104
+ constructor(message, nodes, source, positions, path9, originalError, extensions) {
6105
+ super(message, nodes, source, positions, path9, originalError, extensions);
6034
6106
  this.name = "NotFoundError";
6035
6107
  }
6036
6108
  };
@@ -7292,21 +7364,21 @@ var Database = class {
7292
7364
  edges,
7293
7365
  async ({
7294
7366
  cursor,
7295
- path: path8,
7367
+ path: path9,
7296
7368
  value
7297
7369
  }) => {
7298
7370
  try {
7299
- const node = await hydrator(path8, value);
7371
+ const node = await hydrator(path9, value);
7300
7372
  return {
7301
7373
  node,
7302
7374
  cursor: btoa(cursor)
7303
7375
  };
7304
7376
  } catch (error) {
7305
7377
  console.log(error);
7306
- if (error instanceof Error && (!path8.includes(".tina/__generated__/_graphql.json") || !path8.includes("tina/__generated__/_graphql.json"))) {
7378
+ if (error instanceof Error && (!path9.includes(".tina/__generated__/_graphql.json") || !path9.includes("tina/__generated__/_graphql.json"))) {
7307
7379
  throw new TinaQueryError({
7308
7380
  originalError: error,
7309
- file: path8,
7381
+ file: path9,
7310
7382
  collection: collection.name
7311
7383
  });
7312
7384
  }
@@ -7594,8 +7666,8 @@ var Database = class {
7594
7666
  return { warnings };
7595
7667
  };
7596
7668
  };
7597
- var hashPasswordVisitor = async (node, path8) => {
7598
- const passwordValuePath = [...path8, "value"];
7669
+ var hashPasswordVisitor = async (node, path9) => {
7670
+ const passwordValuePath = [...path9, "value"];
7599
7671
  const plaintextPassword = get(node, passwordValuePath);
7600
7672
  if (plaintextPassword) {
7601
7673
  set2(
@@ -7605,10 +7677,10 @@ var hashPasswordVisitor = async (node, path8) => {
7605
7677
  );
7606
7678
  }
7607
7679
  };
7608
- var visitNodes = async (node, path8, callback) => {
7609
- const [currentLevel, ...remainingLevels] = path8;
7680
+ var visitNodes = async (node, path9, callback) => {
7681
+ const [currentLevel, ...remainingLevels] = path9;
7610
7682
  if (!remainingLevels?.length) {
7611
- return callback(node, path8);
7683
+ return callback(node, path9);
7612
7684
  }
7613
7685
  if (Array.isArray(node[currentLevel])) {
7614
7686
  for (const item of node[currentLevel]) {
@@ -7937,10 +8009,20 @@ var shaExists = async ({
7937
8009
  }) => git.readCommit({ fs: fs4, dir, oid: sha3 }).then(() => true).catch(() => false);
7938
8010
 
7939
8011
  // src/database/bridge/filesystem.ts
7940
- import fs2 from "fs-extra";
7941
- import fg from "fast-glob";
7942
8012
  import path7 from "path";
8013
+ import fg from "fast-glob";
8014
+ import fs2 from "fs-extra";
7943
8015
  import normalize from "normalize-path";
8016
+ function assertWithinBase(filepath, baseDir) {
8017
+ const resolvedBase = path7.resolve(baseDir);
8018
+ const resolved = path7.resolve(path7.join(baseDir, filepath));
8019
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + path7.sep)) {
8020
+ throw new Error(
8021
+ `Path traversal detected: "${filepath}" escapes the base directory`
8022
+ );
8023
+ }
8024
+ return resolved;
8025
+ }
7944
8026
  var FilesystemBridge = class {
7945
8027
  rootPath;
7946
8028
  outputPath;
@@ -7949,7 +8031,7 @@ var FilesystemBridge = class {
7949
8031
  this.outputPath = outputPath ? path7.resolve(outputPath) : this.rootPath;
7950
8032
  }
7951
8033
  async glob(pattern, extension) {
7952
- const basePath = path7.join(this.outputPath, ...pattern.split("/"));
8034
+ const basePath = assertWithinBase(pattern, this.outputPath);
7953
8035
  const items = await fg(
7954
8036
  path7.join(basePath, "**", `/*.${extension}`).replace(/\\/g, "/"),
7955
8037
  {
@@ -7963,14 +8045,17 @@ var FilesystemBridge = class {
7963
8045
  );
7964
8046
  }
7965
8047
  async delete(filepath) {
7966
- await fs2.remove(path7.join(this.outputPath, filepath));
8048
+ const resolved = assertWithinBase(filepath, this.outputPath);
8049
+ await fs2.remove(resolved);
7967
8050
  }
7968
8051
  async get(filepath) {
7969
- return (await fs2.readFile(path7.join(this.outputPath, filepath))).toString();
8052
+ const resolved = assertWithinBase(filepath, this.outputPath);
8053
+ return (await fs2.readFile(resolved)).toString();
7970
8054
  }
7971
8055
  async put(filepath, data, basePathOverride) {
7972
8056
  const basePath = basePathOverride || this.outputPath;
7973
- await fs2.outputFile(path7.join(basePath, filepath), data);
8057
+ const resolved = assertWithinBase(filepath, basePath);
8058
+ await fs2.outputFile(resolved, data);
7974
8059
  }
7975
8060
  };
7976
8061
  var AuditFileSystemBridge = class extends FilesystemBridge {
@@ -7990,12 +8075,21 @@ var AuditFileSystemBridge = class extends FilesystemBridge {
7990
8075
  };
7991
8076
 
7992
8077
  // src/database/bridge/isomorphic.ts
7993
- import git2 from "isomorphic-git";
8078
+ import path8, { dirname } from "path";
7994
8079
  import fs3 from "fs-extra";
7995
8080
  import globParent from "glob-parent";
7996
- import normalize2 from "normalize-path";
7997
8081
  import { GraphQLError as GraphQLError6 } from "graphql";
7998
- import { dirname } from "path";
8082
+ import git2 from "isomorphic-git";
8083
+ import normalize2 from "normalize-path";
8084
+ function assertWithinBase2(filepath, relativePath) {
8085
+ const qualified = relativePath ? `${relativePath}/${filepath}` : filepath;
8086
+ const normalized = path8.normalize(qualified);
8087
+ if (normalized.startsWith("..") || normalized.startsWith("/") || path8.isAbsolute(normalized) || relativePath && normalized !== relativePath && !normalized.startsWith(relativePath + "/")) {
8088
+ throw new Error(
8089
+ `Path traversal detected: "${filepath}" escapes the content root`
8090
+ );
8091
+ }
8092
+ }
7999
8093
  var flat = typeof Array.prototype.flat === "undefined" ? (entries) => entries.reduce((acc, x) => acc.concat(x), []) : (entries) => entries.flat();
8000
8094
  var toUint8Array = (buf) => {
8001
8095
  const ab = new ArrayBuffer(buf.length);
@@ -8074,7 +8168,7 @@ var IsomorphicBridge = class {
8074
8168
  async listEntries({
8075
8169
  pattern,
8076
8170
  entry,
8077
- path: path8,
8171
+ path: path9,
8078
8172
  results
8079
8173
  }) {
8080
8174
  const treeResult = await git2.readTree({
@@ -8084,7 +8178,7 @@ var IsomorphicBridge = class {
8084
8178
  });
8085
8179
  const children = [];
8086
8180
  for (const childEntry of treeResult.tree) {
8087
- const childPath = path8 ? `${path8}/${childEntry.path}` : childEntry.path;
8181
+ const childPath = path9 ? `${path9}/${childEntry.path}` : childEntry.path;
8088
8182
  if (childEntry.type === "tree") {
8089
8183
  children.push(childEntry);
8090
8184
  } else {
@@ -8094,7 +8188,7 @@ var IsomorphicBridge = class {
8094
8188
  }
8095
8189
  }
8096
8190
  for (const childEntry of children) {
8097
- const childPath = path8 ? `${path8}/${childEntry.path}` : childEntry.path;
8191
+ const childPath = path9 ? `${path9}/${childEntry.path}` : childEntry.path;
8098
8192
  await this.listEntries({
8099
8193
  pattern,
8100
8194
  entry: childEntry,
@@ -8112,17 +8206,17 @@ var IsomorphicBridge = class {
8112
8206
  * @param ref - ref to resolve path entries for
8113
8207
  * @private
8114
8208
  */
8115
- async resolvePathEntries(path8, ref) {
8116
- let pathParts = path8.split("/");
8209
+ async resolvePathEntries(path9, ref) {
8210
+ let pathParts = path9.split("/");
8117
8211
  const result = await git2.walk({
8118
8212
  ...this.isomorphicConfig,
8119
8213
  map: async (filepath, [head]) => {
8120
8214
  if (head._fullpath === ".") {
8121
8215
  return head;
8122
8216
  }
8123
- if (path8.startsWith(filepath)) {
8124
- if (dirname(path8) === dirname(filepath)) {
8125
- if (path8 === filepath) {
8217
+ if (path9.startsWith(filepath)) {
8218
+ if (dirname(path9) === dirname(filepath)) {
8219
+ if (path9 === filepath) {
8126
8220
  return head;
8127
8221
  }
8128
8222
  } else {
@@ -8153,7 +8247,7 @@ var IsomorphicBridge = class {
8153
8247
  * @param pathParts - parent path parts
8154
8248
  * @private
8155
8249
  */
8156
- async updateTreeHierarchy(existingOid, updatedOid, path8, type, pathEntries, pathParts) {
8250
+ async updateTreeHierarchy(existingOid, updatedOid, path9, type, pathEntries, pathParts) {
8157
8251
  const lastIdx = pathEntries.length - 1;
8158
8252
  const parentEntry = pathEntries[lastIdx];
8159
8253
  const parentPath = pathParts[lastIdx];
@@ -8168,7 +8262,7 @@ var IsomorphicBridge = class {
8168
8262
  cache: this.cache
8169
8263
  });
8170
8264
  tree = existingOid ? treeResult.tree.map((entry) => {
8171
- if (entry.path === path8) {
8265
+ if (entry.path === path9) {
8172
8266
  entry.oid = updatedOid;
8173
8267
  }
8174
8268
  return entry;
@@ -8177,7 +8271,7 @@ var IsomorphicBridge = class {
8177
8271
  {
8178
8272
  oid: updatedOid,
8179
8273
  type,
8180
- path: path8,
8274
+ path: path9,
8181
8275
  mode
8182
8276
  }
8183
8277
  ];
@@ -8186,7 +8280,7 @@ var IsomorphicBridge = class {
8186
8280
  {
8187
8281
  oid: updatedOid,
8188
8282
  type,
8189
- path: path8,
8283
+ path: path9,
8190
8284
  mode
8191
8285
  }
8192
8286
  ];
@@ -8262,6 +8356,7 @@ var IsomorphicBridge = class {
8262
8356
  return ref;
8263
8357
  }
8264
8358
  async glob(pattern, extension) {
8359
+ assertWithinBase2(pattern, this.relativePath);
8265
8360
  const ref = await this.getRef();
8266
8361
  const parent = globParent(this.qualifyPath(pattern));
8267
8362
  const { pathParts, pathEntries } = await this.resolvePathEntries(
@@ -8295,9 +8390,10 @@ var IsomorphicBridge = class {
8295
8390
  path: parentPath,
8296
8391
  results
8297
8392
  });
8298
- return results.map((path8) => this.unqualifyPath(path8)).filter((path8) => path8.endsWith(extension));
8393
+ return results.map((path9) => this.unqualifyPath(path9)).filter((path9) => path9.endsWith(extension));
8299
8394
  }
8300
8395
  async delete(filepath) {
8396
+ assertWithinBase2(filepath, this.relativePath);
8301
8397
  const ref = await this.getRef();
8302
8398
  const { pathParts, pathEntries } = await this.resolvePathEntries(
8303
8399
  this.qualifyPath(filepath),
@@ -8369,6 +8465,7 @@ var IsomorphicBridge = class {
8369
8465
  return this.relativePath ? filepath.slice(this.relativePath.length + 1) : filepath;
8370
8466
  }
8371
8467
  async get(filepath) {
8468
+ assertWithinBase2(filepath, this.relativePath);
8372
8469
  const ref = await this.getRef();
8373
8470
  const oid = await git2.resolveRef({
8374
8471
  ...this.isomorphicConfig,
@@ -8383,6 +8480,7 @@ var IsomorphicBridge = class {
8383
8480
  return Buffer.from(blob).toString("utf8");
8384
8481
  }
8385
8482
  async put(filepath, data) {
8483
+ assertWithinBase2(filepath, this.relativePath);
8386
8484
  const ref = await this.getRef();
8387
8485
  const { pathParts, pathEntries } = await this.resolvePathEntries(
8388
8486
  this.qualifyPath(filepath),
@@ -228,6 +228,25 @@ export declare class Resolver {
228
228
  */
229
229
  resolveLegacyValues: (oldDoc: any, collection: Collection<true>) => {};
230
230
  private getCollectionWithName;
231
+ /**
232
+ * validatePath ensures that the provided path remains within the boundaries
233
+ * of the collection's directory and that the file extension matches the
234
+ * collection's configured format. This is a critical security check to prevent
235
+ * path traversal attacks where a user might attempt to read or write files
236
+ * outside of the intended collection.
237
+ */
238
+ private validatePath;
239
+ /**
240
+ * Helper method to get collection and construct validated path.
241
+ * This encapsulates the common pattern of getting a collection, joining paths,
242
+ * and validating the result, ensuring security checks are always performed.
243
+ *
244
+ * @param collectionName - Name of the collection
245
+ * @param relativePath - Relative path within the collection
246
+ * @param options - Optional configuration
247
+ * @returns Object containing the collection and validated real path
248
+ */
249
+ private getValidatedPath;
231
250
  resolveRetrievedDocument: ({ collectionName, relativePath, }: {
232
251
  collectionName: string;
233
252
  relativePath: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tinacms/graphql",
3
3
  "type": "module",
4
- "version": "2.1.1",
4
+ "version": "2.1.3",
5
5
  "main": "dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "files": [
@@ -37,14 +37,14 @@
37
37
  "isomorphic-git": "^1.29.0",
38
38
  "js-sha1": "^0.6.0",
39
39
  "js-yaml": "^3.14.1",
40
- "jsonpath-plus": "10.1.0",
40
+ "jsonpath-plus": "^10.3.0",
41
41
  "many-level": "^2.0.0",
42
42
  "micromatch": "4.0.8",
43
43
  "normalize-path": "^3.0.0",
44
44
  "readable-stream": "^4.7.0",
45
45
  "yup": "^1.6.1",
46
- "@tinacms/mdx": "2.0.5",
47
- "@tinacms/schema-tools": "2.5.0"
46
+ "@tinacms/mdx": "2.0.6",
47
+ "@tinacms/schema-tools": "2.6.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "registry": "https://registry.npmjs.org"
@@ -71,8 +71,8 @@
71
71
  "vite": "^4.5.9",
72
72
  "vitest": "^0.32.4",
73
73
  "zod": "^3.24.2",
74
- "@tinacms/scripts": "1.4.2",
75
- "@tinacms/schema-tools": "2.5.0"
74
+ "@tinacms/scripts": "1.5.0",
75
+ "@tinacms/schema-tools": "2.6.0"
76
76
  },
77
77
  "scripts": {
78
78
  "types": "pnpm tsc",