@tinacms/cli 2.1.5 → 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 +175 -90
- package/dist/next/codegen/stripSearchTokenFromConfig.d.ts +7 -0
- package/dist/next/commands/dev-command/server/media.d.ts +18 -1
- package/dist/next/config-manager.d.ts +3 -1
- package/dist/next/vite/filterPublicEnv.d.ts +9 -0
- package/dist/server/models/media.d.ts +17 -0
- package/dist/utils/path.d.ts +26 -0
- package/package.json +7 -7
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.
|
|
5
|
+
var version = "2.1.7";
|
|
6
6
|
|
|
7
7
|
// src/next/commands/dev-command/index.ts
|
|
8
8
|
import path8 from "path";
|
|
@@ -417,6 +417,38 @@ var loadGraphQLDocuments = async (globPath) => {
|
|
|
417
417
|
import { transform } from "esbuild";
|
|
418
418
|
import { mapUserFields } from "@tinacms/graphql";
|
|
419
419
|
import normalizePath from "normalize-path";
|
|
420
|
+
|
|
421
|
+
// src/next/codegen/stripSearchTokenFromConfig.ts
|
|
422
|
+
function stripSearchTokenFromConfig(config2) {
|
|
423
|
+
const cfg = config2;
|
|
424
|
+
if (!cfg?.search) {
|
|
425
|
+
return config2;
|
|
426
|
+
}
|
|
427
|
+
const search = cfg.search;
|
|
428
|
+
const tina = search?.tina;
|
|
429
|
+
if (tina) {
|
|
430
|
+
const { indexerToken, ...safeSearchConfig } = tina;
|
|
431
|
+
const newConfig = {};
|
|
432
|
+
for (const key of Object.keys(cfg)) {
|
|
433
|
+
if (key === "search") {
|
|
434
|
+
newConfig.search = { tina: safeSearchConfig };
|
|
435
|
+
} else {
|
|
436
|
+
newConfig[key] = cfg[key];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return newConfig;
|
|
440
|
+
} else {
|
|
441
|
+
const newConfig = {};
|
|
442
|
+
for (const key of Object.keys(cfg)) {
|
|
443
|
+
if (key !== "search") {
|
|
444
|
+
newConfig[key] = cfg[key];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return newConfig;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/next/codegen/index.ts
|
|
420
452
|
var TINA_HOST = "content.tinajs.io";
|
|
421
453
|
var Codegen = class {
|
|
422
454
|
configManager;
|
|
@@ -487,27 +519,9 @@ var Codegen = class {
|
|
|
487
519
|
"_graphql.json",
|
|
488
520
|
JSON.stringify(this.graphqlSchemaDoc)
|
|
489
521
|
);
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const newConfig = {};
|
|
494
|
-
for (const key of Object.keys(config2)) {
|
|
495
|
-
if (key === "search") {
|
|
496
|
-
newConfig.search = { tina: safeSearchConfig };
|
|
497
|
-
} else {
|
|
498
|
-
newConfig[key] = config2[key];
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
this.tinaSchema.schema.config = newConfig;
|
|
502
|
-
} else if (config2?.search) {
|
|
503
|
-
const newConfig = {};
|
|
504
|
-
for (const key of Object.keys(config2)) {
|
|
505
|
-
if (key !== "search") {
|
|
506
|
-
newConfig[key] = config2[key];
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
this.tinaSchema.schema.config = newConfig;
|
|
510
|
-
}
|
|
522
|
+
this.tinaSchema.schema.config = stripSearchTokenFromConfig(
|
|
523
|
+
this.tinaSchema.schema.config
|
|
524
|
+
);
|
|
511
525
|
await this.writeConfigFile(
|
|
512
526
|
"_schema.json",
|
|
513
527
|
JSON.stringify(this.tinaSchema.schema)
|
|
@@ -803,6 +817,14 @@ function stripNativeTrailingSlash(p) {
|
|
|
803
817
|
}
|
|
804
818
|
return str;
|
|
805
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
|
+
};
|
|
806
828
|
|
|
807
829
|
// src/next/config-manager.ts
|
|
808
830
|
var TINA_FOLDER = "tina";
|
|
@@ -1017,13 +1039,15 @@ var ConfigManager = class {
|
|
|
1017
1039
|
this.contentRootPath = this.rootPath;
|
|
1018
1040
|
}
|
|
1019
1041
|
this.generatedFolderPathContentRepo = path3.join(
|
|
1020
|
-
await this.getTinaFolderPath(this.contentRootPath
|
|
1042
|
+
await this.getTinaFolderPath(this.contentRootPath, {
|
|
1043
|
+
isContentRoot: this.hasSeparateContentRoot()
|
|
1044
|
+
}),
|
|
1021
1045
|
GENERATED_FOLDER
|
|
1022
1046
|
);
|
|
1023
1047
|
this.spaMainPath = require2.resolve("@tinacms/app");
|
|
1024
1048
|
this.spaRootPath = path3.join(this.spaMainPath, "..", "..");
|
|
1025
1049
|
}
|
|
1026
|
-
async getTinaFolderPath(rootPath) {
|
|
1050
|
+
async getTinaFolderPath(rootPath, { isContentRoot } = {}) {
|
|
1027
1051
|
const tinaFolderPath = path3.join(rootPath, TINA_FOLDER);
|
|
1028
1052
|
const tinaFolderExists = await fs2.pathExists(tinaFolderPath);
|
|
1029
1053
|
if (tinaFolderExists) {
|
|
@@ -1036,6 +1060,11 @@ var ConfigManager = class {
|
|
|
1036
1060
|
this.isUsingLegacyFolder = true;
|
|
1037
1061
|
return legacyFolderPath;
|
|
1038
1062
|
}
|
|
1063
|
+
if (isContentRoot) {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
`Unable to find a ${chalk3.cyan("tina/")} folder in your content root at ${chalk3.cyan(rootPath)}. When using localContentPath, the content directory must contain a ${chalk3.cyan("tina/")} folder for generated files. Create one with: mkdir ${path3.join(rootPath, TINA_FOLDER)}`
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1039
1068
|
throw new Error(
|
|
1040
1069
|
`Unable to find Tina folder, if you're working in folder outside of the Tina config be sure to specify --rootPath`
|
|
1041
1070
|
);
|
|
@@ -1557,6 +1586,29 @@ import {
|
|
|
1557
1586
|
splitVendorChunkPlugin
|
|
1558
1587
|
} from "vite";
|
|
1559
1588
|
|
|
1589
|
+
// src/next/vite/filterPublicEnv.ts
|
|
1590
|
+
function filterPublicEnv(env = process.env) {
|
|
1591
|
+
const publicEnv = {};
|
|
1592
|
+
Object.keys(env).forEach((key) => {
|
|
1593
|
+
if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
|
|
1594
|
+
try {
|
|
1595
|
+
const value = env[key];
|
|
1596
|
+
if (typeof value === "string") {
|
|
1597
|
+
publicEnv[key] = value;
|
|
1598
|
+
} else {
|
|
1599
|
+
publicEnv[key] = JSON.stringify(value);
|
|
1600
|
+
}
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
console.warn(
|
|
1603
|
+
`Could not stringify public env process.env.${key} env variable`
|
|
1604
|
+
);
|
|
1605
|
+
console.warn(error);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
return publicEnv;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1560
1612
|
// src/next/vite/tailwind.ts
|
|
1561
1613
|
import path4 from "node:path";
|
|
1562
1614
|
import aspectRatio from "@tailwindcss/aspect-ratio";
|
|
@@ -1889,23 +1941,7 @@ var createConfig = async ({
|
|
|
1889
1941
|
noWatch,
|
|
1890
1942
|
rollupOptions
|
|
1891
1943
|
}) => {
|
|
1892
|
-
const publicEnv =
|
|
1893
|
-
Object.keys(process.env).forEach((key) => {
|
|
1894
|
-
if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
|
|
1895
|
-
try {
|
|
1896
|
-
if (typeof process.env[key] === "string") {
|
|
1897
|
-
publicEnv[key] = process.env[key];
|
|
1898
|
-
} else {
|
|
1899
|
-
publicEnv[key] = JSON.stringify(process.env[key]);
|
|
1900
|
-
}
|
|
1901
|
-
} catch (error) {
|
|
1902
|
-
console.warn(
|
|
1903
|
-
`Could not stringify public env process.env.${key} env variable`
|
|
1904
|
-
);
|
|
1905
|
-
console.warn(error);
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
});
|
|
1944
|
+
const publicEnv = filterPublicEnv();
|
|
1909
1945
|
const staticMediaPath = path5.join(
|
|
1910
1946
|
configManager.generatedFolderPath,
|
|
1911
1947
|
"static-media.json"
|
|
@@ -2035,9 +2071,9 @@ import cors from "cors";
|
|
|
2035
2071
|
import { resolve as gqlResolve } from "@tinacms/graphql";
|
|
2036
2072
|
|
|
2037
2073
|
// src/next/commands/dev-command/server/media.ts
|
|
2038
|
-
import fs5 from "fs-extra";
|
|
2039
2074
|
import path6, { join } from "path";
|
|
2040
2075
|
import busboy from "busboy";
|
|
2076
|
+
import fs5 from "fs-extra";
|
|
2041
2077
|
var createMediaRouter = (config2) => {
|
|
2042
2078
|
const mediaFolder = path6.join(
|
|
2043
2079
|
config2.rootPath,
|
|
@@ -2046,31 +2082,68 @@ var createMediaRouter = (config2) => {
|
|
|
2046
2082
|
);
|
|
2047
2083
|
const mediaModel = new MediaModel(config2);
|
|
2048
2084
|
const handleList = async (req, res) => {
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
cursor
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
+
}
|
|
2059
2106
|
};
|
|
2060
2107
|
const handleDelete = async (req, res) => {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
+
}
|
|
2064
2120
|
};
|
|
2065
2121
|
const handlePost = async function(req, res) {
|
|
2066
2122
|
const bb = busboy({ headers: req.headers });
|
|
2123
|
+
let responded = false;
|
|
2067
2124
|
bb.on("file", async (_name, file, _info) => {
|
|
2068
|
-
const fullPath =
|
|
2069
|
-
|
|
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
|
+
}
|
|
2070
2142
|
await fs5.ensureDir(path6.dirname(saveTo));
|
|
2071
2143
|
file.pipe(fs5.createWriteStream(saveTo));
|
|
2072
2144
|
});
|
|
2073
2145
|
bb.on("error", (error) => {
|
|
2146
|
+
responded = true;
|
|
2074
2147
|
res.statusCode = 500;
|
|
2075
2148
|
if (error instanceof Error) {
|
|
2076
2149
|
res.end(JSON.stringify({ message: error }));
|
|
@@ -2079,6 +2152,7 @@ var createMediaRouter = (config2) => {
|
|
|
2079
2152
|
}
|
|
2080
2153
|
});
|
|
2081
2154
|
bb.on("close", () => {
|
|
2155
|
+
if (responded) return;
|
|
2082
2156
|
res.statusCode = 200;
|
|
2083
2157
|
res.end(JSON.stringify({ success: true }));
|
|
2084
2158
|
});
|
|
@@ -2093,6 +2167,32 @@ var parseMediaFolder = (str) => {
|
|
|
2093
2167
|
returnString = returnString.substr(0, returnString.length - 1);
|
|
2094
2168
|
return returnString;
|
|
2095
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
|
+
}
|
|
2096
2196
|
var MediaModel = class {
|
|
2097
2197
|
rootPath;
|
|
2098
2198
|
publicFolder;
|
|
@@ -2104,22 +2204,18 @@ var MediaModel = class {
|
|
|
2104
2204
|
}
|
|
2105
2205
|
async listMedia(args) {
|
|
2106
2206
|
try {
|
|
2107
|
-
const
|
|
2108
|
-
|
|
2109
|
-
this.publicFolder,
|
|
2110
|
-
this.mediaRoot,
|
|
2111
|
-
decodeURIComponent(args.searchPath)
|
|
2112
|
-
);
|
|
2207
|
+
const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
|
|
2208
|
+
const validatedPath = resolveWithinBase(args.searchPath, mediaBase);
|
|
2113
2209
|
const searchPath = parseMediaFolder(args.searchPath);
|
|
2114
|
-
if (!await fs5.pathExists(
|
|
2210
|
+
if (!await fs5.pathExists(validatedPath)) {
|
|
2115
2211
|
return {
|
|
2116
2212
|
files: [],
|
|
2117
2213
|
directories: []
|
|
2118
2214
|
};
|
|
2119
2215
|
}
|
|
2120
|
-
const filesStr = await fs5.readdir(
|
|
2216
|
+
const filesStr = await fs5.readdir(validatedPath);
|
|
2121
2217
|
const filesProm = filesStr.map(async (file) => {
|
|
2122
|
-
const filePath = join(
|
|
2218
|
+
const filePath = join(validatedPath, file);
|
|
2123
2219
|
const stat = await fs5.stat(filePath);
|
|
2124
2220
|
let src = `/${file}`;
|
|
2125
2221
|
const isFile = stat.isFile();
|
|
@@ -2166,6 +2262,7 @@ var MediaModel = class {
|
|
|
2166
2262
|
cursor
|
|
2167
2263
|
};
|
|
2168
2264
|
} catch (error) {
|
|
2265
|
+
if (error instanceof PathTraversalError) throw error;
|
|
2169
2266
|
console.error(error);
|
|
2170
2267
|
return {
|
|
2171
2268
|
files: [],
|
|
@@ -2176,16 +2273,13 @@ var MediaModel = class {
|
|
|
2176
2273
|
}
|
|
2177
2274
|
async deleteMedia(args) {
|
|
2178
2275
|
try {
|
|
2179
|
-
const
|
|
2180
|
-
|
|
2181
|
-
this.publicFolder,
|
|
2182
|
-
this.mediaRoot,
|
|
2183
|
-
decodeURIComponent(args.searchPath)
|
|
2184
|
-
);
|
|
2276
|
+
const mediaBase = join(this.rootPath, this.publicFolder, this.mediaRoot);
|
|
2277
|
+
const file = resolveStrictlyWithinBase(args.searchPath, mediaBase);
|
|
2185
2278
|
await fs5.stat(file);
|
|
2186
2279
|
await fs5.remove(file);
|
|
2187
2280
|
return { ok: true };
|
|
2188
2281
|
} catch (error) {
|
|
2282
|
+
if (error instanceof PathTraversalError) throw error;
|
|
2189
2283
|
console.error(error);
|
|
2190
2284
|
return { ok: false, message: error?.toString() };
|
|
2191
2285
|
}
|
|
@@ -2555,7 +2649,8 @@ var DevCommand = class extends BaseCommand {
|
|
|
2555
2649
|
);
|
|
2556
2650
|
if (configManager.hasSeparateContentRoot()) {
|
|
2557
2651
|
const rootPath = await configManager.getTinaFolderPath(
|
|
2558
|
-
configManager.contentRootPath
|
|
2652
|
+
configManager.contentRootPath,
|
|
2653
|
+
{ isContentRoot: true }
|
|
2559
2654
|
);
|
|
2560
2655
|
const filePath = path8.join(rootPath, tinaLockFilename);
|
|
2561
2656
|
await fs7.ensureFile(filePath);
|
|
@@ -2731,6 +2826,13 @@ ${dangerText(e.message)}
|
|
|
2731
2826
|
// },
|
|
2732
2827
|
]
|
|
2733
2828
|
});
|
|
2829
|
+
if (configManager?.config?.telemetry === "anonymous") {
|
|
2830
|
+
logger.info(
|
|
2831
|
+
`
|
|
2832
|
+
\u{1F4CA} Note: TinaCMS now collects anonymous telemetry regarding usage. More information on TinaCMS Telemetry: https://tina.io/telemetry
|
|
2833
|
+
`
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2734
2836
|
await this.startSubCommand();
|
|
2735
2837
|
}
|
|
2736
2838
|
watchContentFiles(configManager, database, databaseLock, searchIndexer) {
|
|
@@ -2831,23 +2933,6 @@ async function sleepAndCallFunc({
|
|
|
2831
2933
|
// src/next/commands/build-command/server.ts
|
|
2832
2934
|
import { build as build2 } from "vite";
|
|
2833
2935
|
var buildProductionSpa = async (configManager, database, apiURL) => {
|
|
2834
|
-
const publicEnv = {};
|
|
2835
|
-
Object.keys(process.env).forEach((key) => {
|
|
2836
|
-
if (key.startsWith("TINA_PUBLIC_") || key.startsWith("NEXT_PUBLIC_") || key === "NODE_ENV" || key === "HEAD") {
|
|
2837
|
-
try {
|
|
2838
|
-
if (typeof process.env[key] === "string") {
|
|
2839
|
-
publicEnv[key] = process.env[key];
|
|
2840
|
-
} else {
|
|
2841
|
-
publicEnv[key] = JSON.stringify(process.env[key]);
|
|
2842
|
-
}
|
|
2843
|
-
} catch (error) {
|
|
2844
|
-
console.warn(
|
|
2845
|
-
`Could not stringify public env process.env.${key} env variable`
|
|
2846
|
-
);
|
|
2847
|
-
console.warn(error);
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
});
|
|
2851
2936
|
const config2 = await createConfig({
|
|
2852
2937
|
plugins: [transformTsxPlugin({ configManager }), viteTransformExtension()],
|
|
2853
2938
|
configManager,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips `indexerToken` from `search.tina` before serialization to
|
|
3
|
+
* _schema.json / tina-lock.json.
|
|
4
|
+
*
|
|
5
|
+
* @see https://github.com/tinacms/tinacms/security/advisories/GHSA-4qrm-9h4r-v2fx
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripSearchTokenFromConfig<T extends object>(config: T): T;
|
|
@@ -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;
|
|
@@ -54,7 +54,9 @@ export declare class ConfigManager {
|
|
|
54
54
|
hasSeparateContentRoot(): boolean;
|
|
55
55
|
shouldSkipSDK(): boolean;
|
|
56
56
|
processConfig(): Promise<void>;
|
|
57
|
-
getTinaFolderPath(rootPath:
|
|
57
|
+
getTinaFolderPath(rootPath: string, { isContentRoot }?: {
|
|
58
|
+
isContentRoot?: boolean;
|
|
59
|
+
}): Promise<string>;
|
|
58
60
|
getTinaGraphQLVersion(): {
|
|
59
61
|
fullVersion: string;
|
|
60
62
|
major: string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filters env vars to only those safe for client-side bundles.
|
|
3
|
+
*
|
|
4
|
+
* Allows: TINA_PUBLIC_*, NEXT_PUBLIC_*, NODE_ENV, HEAD.
|
|
5
|
+
* Everything else is excluded to prevent leaking secrets.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/tinacms/tinacms/security/advisories/GHSA-pc2q-jcxq-rjrr
|
|
8
|
+
*/
|
|
9
|
+
export declare function filterPublicEnv(env?: Record<string, string | undefined>): Record<string, 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;
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "2.1.7",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@types/progress": "^2.0.7",
|
|
42
42
|
"@types/prompts": "^2.4.9",
|
|
43
43
|
"jest": "^29.7.0",
|
|
44
|
-
"@tinacms/scripts": "1.
|
|
44
|
+
"@tinacms/scripts": "1.5.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@graphql-codegen/core": "^2.6.8",
|
|
@@ -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.
|
|
92
|
-
"@tinacms/graphql": "2.1.
|
|
91
|
+
"@tinacms/app": "2.3.26",
|
|
92
|
+
"@tinacms/graphql": "2.1.3",
|
|
93
93
|
"@tinacms/metrics": "2.0.1",
|
|
94
|
-
"@tinacms/schema-tools": "2.
|
|
95
|
-
"@tinacms/search": "1.2.
|
|
96
|
-
"tinacms": "3.
|
|
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"
|