@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.
|
|
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
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
cursor
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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 =
|
|
2097
|
-
|
|
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
|
|
2136
|
-
|
|
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(
|
|
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(
|
|
2216
|
+
const filesStr = await fs5.readdir(validatedPath);
|
|
2149
2217
|
const filesProm = filesStr.map(async (file) => {
|
|
2150
|
-
const filePath = join(
|
|
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
|
|
2208
|
-
|
|
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;
|
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": [
|
|
@@ -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/
|
|
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/
|
|
96
|
-
"tinacms": "
|
|
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"
|