@malloy-publisher/server 0.0.198-dev → 0.0.198
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/README.docker.md +135 -20
- package/README.md +15 -0
- package/build.ts +16 -0
- package/dist/app/assets/EnvironmentPage-C7rtH4mC.js +1 -0
- package/dist/app/assets/HomePage-DwkH7OrS.js +1 -0
- package/dist/app/assets/MainPage-D38LtZDV.js +2 -0
- package/dist/app/assets/ModelPage-DOol8Mz7.js +1 -0
- package/dist/app/assets/PackagePage-0tgzA_kO.js +1 -0
- package/dist/app/assets/RouteError-BaMsOSly.js +1 -0
- package/dist/app/assets/WorkbookPage-Cx4SePkx.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-CbsC6R_Y.es-Cwf6asf3.js} +14 -14
- package/dist/app/assets/index-DL6BZTuw.js +1803 -0
- package/dist/app/assets/{index-C513UodQ.js → index-DNofXMxi.js} +15 -15
- package/dist/app/assets/index-U38AyjJL.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-B68wGGkM.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +1 -3
- package/dist/server.mjs +1104 -567
- package/package.json +11 -12
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/publisher.config.json +1 -11
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/pg_helpers.spec.ts +226 -0
- package/src/pg_helpers.ts +129 -0
- package/src/server-old.ts +3 -23
- package/src/server.ts +33 -0
- package/src/service/connection.spec.ts +6 -4
- package/src/service/connection.ts +8 -3
- package/src/service/connection_config.ts +2 -2
- package/src/service/environment.ts +619 -175
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/package.ts +4 -3
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/src/storage/StorageManager.ts +71 -11
- package/src/storage/duckdb/schema.ts +41 -0
- package/src/utils.ts +11 -0
- package/tests/harness/rest_e2e.ts +2 -2
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
- package/tests/unit/duckdb/attached_databases.test.ts +5 -5
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
- package/tests/unit/storage/StorageManager.test.ts +166 -0
- package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
- package/dist/app/assets/HomePage-DMop21VG.js +0 -1
- package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
- package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
- package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
- package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
- package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
- package/dist/app/assets/index-5K9YjIxF.js +0 -456
- package/dist/app/assets/index-DIgzgp69.js +0 -1742
|
@@ -91,7 +91,7 @@ function validateAdminAuthoredConnection(
|
|
|
91
91
|
): void {
|
|
92
92
|
if (connectionName === "duckdb" || connectionConfig.name === "duckdb") {
|
|
93
93
|
throw new BadRequestError(
|
|
94
|
-
"
|
|
94
|
+
"Connection name 'duckdb' is reserved for per-package sandboxes. Choose a different name for environment-level DuckDB connections (e.g. 'shared_duckdb').",
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import * as path from "path";
|
|
2
1
|
import { components } from "../api";
|
|
3
|
-
import { PUBLISHER_DATA_DIR } from "../constants";
|
|
4
2
|
import { BadRequestError, FrozenConfigError } from "../errors";
|
|
5
3
|
import { logger } from "../logger";
|
|
6
4
|
import { EnvironmentStore } from "../service/environment_store";
|
|
@@ -37,15 +35,38 @@ export class PackageController {
|
|
|
37
35
|
environmentName,
|
|
38
36
|
false,
|
|
39
37
|
);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
|
|
39
|
+
if (reload) {
|
|
40
|
+
// Resolve the package's source location from the currently-cached
|
|
41
|
+
// metadata WITHOUT triggering a stale-state reload. If a `location`
|
|
42
|
+
// is set, route the reload through `installPackage` so that
|
|
43
|
+
// download-then-load happens atomically; otherwise fall back to an
|
|
44
|
+
// in-place reload of the existing on-disk content.
|
|
45
|
+
let location: string | undefined;
|
|
46
|
+
try {
|
|
47
|
+
const cached = await environment.getPackage(packageName, false);
|
|
48
|
+
location = cached.getPackageMetadata().location;
|
|
49
|
+
} catch {
|
|
50
|
+
// Not previously loaded — nothing to reinstall from.
|
|
51
|
+
}
|
|
52
|
+
if (location) {
|
|
53
|
+
const reinstalled = await environment.installPackage(
|
|
54
|
+
packageName,
|
|
55
|
+
(stagingPath) =>
|
|
56
|
+
this.downloadInto(
|
|
57
|
+
environmentName,
|
|
58
|
+
packageName,
|
|
59
|
+
location,
|
|
60
|
+
stagingPath,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
return reinstalled.getPackageMetadata();
|
|
64
|
+
}
|
|
65
|
+
const _package = await environment.getPackage(packageName, true);
|
|
66
|
+
return _package.getPackageMetadata();
|
|
48
67
|
}
|
|
68
|
+
|
|
69
|
+
const _package = await environment.getPackage(packageName, false);
|
|
49
70
|
return _package.getPackageMetadata();
|
|
50
71
|
}
|
|
51
72
|
|
|
@@ -60,21 +81,32 @@ export class PackageController {
|
|
|
60
81
|
if (!body.name) {
|
|
61
82
|
throw new BadRequestError("Package name is required");
|
|
62
83
|
}
|
|
84
|
+
const packageName = body.name;
|
|
63
85
|
const environment = await this.environmentStore.getEnvironment(
|
|
64
86
|
environmentName,
|
|
65
87
|
false,
|
|
66
88
|
);
|
|
89
|
+
let result;
|
|
67
90
|
if (body.location) {
|
|
68
|
-
|
|
91
|
+
const bodyLocation = body.location;
|
|
92
|
+
result = await environment.installPackage(packageName, (stagingPath) =>
|
|
93
|
+
this.downloadInto(
|
|
94
|
+
environmentName,
|
|
95
|
+
packageName,
|
|
96
|
+
bodyLocation,
|
|
97
|
+
stagingPath,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
result = await environment.addPackage(packageName);
|
|
69
102
|
}
|
|
70
|
-
const result = await environment.addPackage(body.name);
|
|
71
103
|
await this.environmentStore.addPackageToDatabase(
|
|
72
104
|
environmentName,
|
|
73
|
-
|
|
105
|
+
packageName,
|
|
74
106
|
);
|
|
75
107
|
|
|
76
108
|
if (options?.autoLoadManifest === true) {
|
|
77
|
-
await this.tryLoadExistingManifest(environmentName,
|
|
109
|
+
await this.tryLoadExistingManifest(environmentName, packageName);
|
|
78
110
|
}
|
|
79
111
|
|
|
80
112
|
return result;
|
|
@@ -151,12 +183,20 @@ export class PackageController {
|
|
|
151
183
|
false,
|
|
152
184
|
);
|
|
153
185
|
if (body.location) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
186
|
+
// Re-install: stream the new content into a staging dir (no lock)
|
|
187
|
+
// and atomically swap it in (under the lock).
|
|
188
|
+
const bodyLocation = body.location;
|
|
189
|
+
await environment.installPackage(packageName, (stagingPath) =>
|
|
190
|
+
this.downloadInto(
|
|
191
|
+
environmentName,
|
|
192
|
+
packageName,
|
|
193
|
+
bodyLocation,
|
|
194
|
+
stagingPath,
|
|
195
|
+
),
|
|
158
196
|
);
|
|
159
197
|
}
|
|
198
|
+
// Apply metadata changes (publisher.json) under the same per-package
|
|
199
|
+
// mutex via `Environment.updatePackage`.
|
|
160
200
|
const result = await environment.updatePackage(packageName, body);
|
|
161
201
|
await this.environmentStore.addPackageToDatabase(
|
|
162
202
|
environmentName,
|
|
@@ -166,17 +206,18 @@ export class PackageController {
|
|
|
166
206
|
return result;
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Run the right downloader for the given location into `targetPath`.
|
|
211
|
+
* This used to point at the canonical package directory, but the
|
|
212
|
+
* install pipeline now passes a sibling staging dir so the long-running
|
|
213
|
+
* download doesn't hold the per-package mutex.
|
|
214
|
+
*/
|
|
215
|
+
private async downloadInto(
|
|
170
216
|
environmentName: string,
|
|
171
217
|
packageName: string,
|
|
172
218
|
packageLocation: string,
|
|
219
|
+
targetPath: string,
|
|
173
220
|
) {
|
|
174
|
-
const absoluteTargetPath = path.join(
|
|
175
|
-
this.environmentStore.serverRootPath,
|
|
176
|
-
PUBLISHER_DATA_DIR,
|
|
177
|
-
environmentName,
|
|
178
|
-
packageName,
|
|
179
|
-
);
|
|
180
221
|
const isCompressedFile = packageLocation.endsWith(".zip");
|
|
181
222
|
if (
|
|
182
223
|
packageLocation.startsWith("https://") ||
|
|
@@ -184,20 +225,20 @@ export class PackageController {
|
|
|
184
225
|
) {
|
|
185
226
|
await this.environmentStore.downloadGitHubDirectory(
|
|
186
227
|
packageLocation,
|
|
187
|
-
|
|
228
|
+
targetPath,
|
|
188
229
|
);
|
|
189
230
|
} else if (packageLocation.startsWith("gs://")) {
|
|
190
231
|
await this.environmentStore.downloadGcsDirectory(
|
|
191
232
|
packageLocation,
|
|
192
233
|
environmentName,
|
|
193
|
-
|
|
234
|
+
targetPath,
|
|
194
235
|
isCompressedFile,
|
|
195
236
|
);
|
|
196
237
|
} else if (packageLocation.startsWith("s3://")) {
|
|
197
238
|
await this.environmentStore.downloadS3Directory(
|
|
198
239
|
packageLocation,
|
|
199
240
|
environmentName,
|
|
200
|
-
|
|
241
|
+
targetPath,
|
|
201
242
|
isCompressedFile,
|
|
202
243
|
);
|
|
203
244
|
}
|
|
@@ -207,7 +248,7 @@ export class PackageController {
|
|
|
207
248
|
// so we need to mount them on the right place.
|
|
208
249
|
await this.environmentStore.mountLocalDirectory(
|
|
209
250
|
packageLocation,
|
|
210
|
-
|
|
251
|
+
targetPath,
|
|
211
252
|
environmentName,
|
|
212
253
|
packageName,
|
|
213
254
|
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"frozenConfig": false,
|
|
3
|
+
"environments": [
|
|
4
|
+
{
|
|
5
|
+
"name": "malloy-samples",
|
|
6
|
+
"packages": [
|
|
7
|
+
{
|
|
8
|
+
"name": "ecommerce",
|
|
9
|
+
"location": "https://github.com/credibledata/malloy-samples/tree/main/ecommerce"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "imdb",
|
|
13
|
+
"location": "https://github.com/credibledata/malloy-samples/tree/main/imdb"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "faa",
|
|
17
|
+
"location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"connections": []
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BadRequestError,
|
|
4
|
+
ConnectionAuthError,
|
|
5
|
+
ConnectionError,
|
|
6
|
+
internalErrorToHttpError,
|
|
7
|
+
} from "./errors";
|
|
8
|
+
|
|
9
|
+
describe("internalErrorToHttpError", () => {
|
|
10
|
+
it("maps ConnectionAuthError to 422", () => {
|
|
11
|
+
const { status, json } = internalErrorToHttpError(
|
|
12
|
+
new ConnectionAuthError("creds rejected for db_x"),
|
|
13
|
+
);
|
|
14
|
+
expect(status).toBe(422);
|
|
15
|
+
expect(json).toEqual({
|
|
16
|
+
code: 422,
|
|
17
|
+
message: "creds rejected for db_x",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("maps BadRequestError to 400", () => {
|
|
22
|
+
const { status, json } = internalErrorToHttpError(
|
|
23
|
+
new BadRequestError("bad input"),
|
|
24
|
+
);
|
|
25
|
+
expect(status).toBe(400);
|
|
26
|
+
expect(json).toEqual({ code: 400, message: "bad input" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps ConnectionError to 502 (distinct from auth, still retryable)", () => {
|
|
30
|
+
const { status, json } = internalErrorToHttpError(
|
|
31
|
+
new ConnectionError("upstream broken"),
|
|
32
|
+
);
|
|
33
|
+
expect(status).toBe(502);
|
|
34
|
+
expect(json).toEqual({ code: 502, message: "upstream broken" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls through to 500 for unrecognized errors", () => {
|
|
38
|
+
const { status, json } = internalErrorToHttpError(new Error("boom"));
|
|
39
|
+
expect(status).toBe(500);
|
|
40
|
+
expect(json.message).toBe("boom");
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -16,6 +16,8 @@ export function internalErrorToHttpError(error: Error) {
|
|
|
16
16
|
return httpError(400, error.message);
|
|
17
17
|
} else if (error instanceof ConnectionNotFoundError) {
|
|
18
18
|
return httpError(404, error.message);
|
|
19
|
+
} else if (error instanceof ConnectionAuthError) {
|
|
20
|
+
return httpError(422, error.message);
|
|
19
21
|
} else if (error instanceof ModelCompilationError) {
|
|
20
22
|
return httpError(424, error.message);
|
|
21
23
|
} else if (error instanceof ConnectionError) {
|
|
@@ -26,6 +28,8 @@ export function internalErrorToHttpError(error: Error) {
|
|
|
26
28
|
return httpError(409, error.message);
|
|
27
29
|
} else if (error instanceof InvalidStateTransitionError) {
|
|
28
30
|
return httpError(409, error.message);
|
|
31
|
+
} else if (error instanceof ServiceUnavailableError) {
|
|
32
|
+
return httpError(503, error.message);
|
|
29
33
|
} else {
|
|
30
34
|
return httpError(500, error.message);
|
|
31
35
|
}
|
|
@@ -83,6 +87,12 @@ export class ConnectionError extends Error {
|
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
export class ConnectionAuthError extends Error {
|
|
91
|
+
constructor(message: string) {
|
|
92
|
+
super(message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
export class ModelCompilationError extends Error {
|
|
87
97
|
constructor(error: MalloyError) {
|
|
88
98
|
super(error.message);
|
|
@@ -114,3 +124,14 @@ export class InvalidStateTransitionError extends Error {
|
|
|
114
124
|
super(message);
|
|
115
125
|
}
|
|
116
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Thrown when the publisher is temporarily refusing a request to keep
|
|
130
|
+
* RSS under the configured `PUBLISHER_MAX_MEMORY_BYTES` cap. Mapped to
|
|
131
|
+
* HTTP 503 so an upstream proxy / client can retry with back-off.
|
|
132
|
+
*/
|
|
133
|
+
export class ServiceUnavailableError extends Error {
|
|
134
|
+
constructor(message: string) {
|
|
135
|
+
super(message);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -28,9 +28,7 @@ export const logger = winston.createLogger({
|
|
|
28
28
|
? winston.format.combine(
|
|
29
29
|
winston.format.uncolorize(),
|
|
30
30
|
winston.format.timestamp(),
|
|
31
|
-
winston.format.
|
|
32
|
-
fillExcept: ["message", "level", "timestamp"],
|
|
33
|
-
}),
|
|
31
|
+
winston.format.errors({ stack: true }),
|
|
34
32
|
winston.format.json(),
|
|
35
33
|
)
|
|
36
34
|
: winston.format.combine(
|
|
@@ -222,8 +222,12 @@ export function registerTools(
|
|
|
222
222
|
throw new Error(`Model not found: ${modelPath}`);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
//
|
|
226
|
-
|
|
225
|
+
// Route through the Environment so the disk read is serialized
|
|
226
|
+
// against installPackage / deletePackage.
|
|
227
|
+
const fileText = await environment.getModelFileText(
|
|
228
|
+
packageName,
|
|
229
|
+
modelPath,
|
|
230
|
+
);
|
|
227
231
|
|
|
228
232
|
console.log(
|
|
229
233
|
`[MCP LOG] Successfully retrieved model text for ${modelPath}`,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
import { BadRequestError } from "./errors";
|
|
5
|
+
import {
|
|
6
|
+
assertSafeEnvironmentPath,
|
|
7
|
+
assertSafePackageName,
|
|
8
|
+
assertSafeRelativeModelPath,
|
|
9
|
+
safeJoinUnderRoot,
|
|
10
|
+
} from "./path_safety";
|
|
11
|
+
|
|
12
|
+
describe("assertSafePackageName", () => {
|
|
13
|
+
it.each([
|
|
14
|
+
"pkg",
|
|
15
|
+
"test_package",
|
|
16
|
+
"test-package",
|
|
17
|
+
"TestPackage1",
|
|
18
|
+
"test.package.name",
|
|
19
|
+
"a",
|
|
20
|
+
"x".repeat(255),
|
|
21
|
+
])("accepts %p", (name) => {
|
|
22
|
+
expect(() => assertSafePackageName(name)).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each([
|
|
26
|
+
["empty", ""],
|
|
27
|
+
["dot", "."],
|
|
28
|
+
["dot-dot", ".."],
|
|
29
|
+
["leading dot", ".staging"],
|
|
30
|
+
["forward slash", "foo/bar"],
|
|
31
|
+
["backslash", "foo\\bar"],
|
|
32
|
+
["null byte", "foo\0bar"],
|
|
33
|
+
["traversal", "../etc/passwd"],
|
|
34
|
+
["abs", "/etc/passwd"],
|
|
35
|
+
["space", "my pkg"],
|
|
36
|
+
["unicode", "pkg\u202E"],
|
|
37
|
+
["too long", "x".repeat(256)],
|
|
38
|
+
])("rejects %s (%p)", (_label, name) => {
|
|
39
|
+
expect(() => assertSafePackageName(name)).toThrow(BadRequestError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
["number", 42],
|
|
44
|
+
["null", null],
|
|
45
|
+
["undefined", undefined],
|
|
46
|
+
["object", { name: "pkg" }],
|
|
47
|
+
])("rejects non-string %s (%p)", (_label, value) => {
|
|
48
|
+
expect(() => assertSafePackageName(value)).toThrow(BadRequestError);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("assertSafeRelativeModelPath", () => {
|
|
53
|
+
it.each([
|
|
54
|
+
"model.malloy",
|
|
55
|
+
"models/foo.malloy",
|
|
56
|
+
"a/b/c/d.malloynb",
|
|
57
|
+
"deep/nested/file_name-1.malloy",
|
|
58
|
+
])("accepts %p", (modelPath) => {
|
|
59
|
+
expect(() => assertSafeRelativeModelPath(modelPath)).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
["empty", ""],
|
|
64
|
+
["leading slash (absolute)", "/etc/passwd"],
|
|
65
|
+
["traversal", "../etc/passwd"],
|
|
66
|
+
["embedded traversal", "models/../../../etc/passwd"],
|
|
67
|
+
["embedded dot segment", "models/./foo.malloy"],
|
|
68
|
+
["double slash", "models//foo.malloy"],
|
|
69
|
+
["trailing slash", "models/foo/"],
|
|
70
|
+
["backslash", "models\\foo.malloy"],
|
|
71
|
+
["null byte", "models/foo\0.malloy"],
|
|
72
|
+
["dotfile segment", ".staging/foo.malloy"],
|
|
73
|
+
["dotfile leaf", "models/.hidden.malloy"],
|
|
74
|
+
])("rejects %s (%p)", (_label, modelPath) => {
|
|
75
|
+
expect(() => assertSafeRelativeModelPath(modelPath)).toThrow(
|
|
76
|
+
BadRequestError,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects non-string inputs", () => {
|
|
81
|
+
expect(() => assertSafeRelativeModelPath(undefined)).toThrow(
|
|
82
|
+
BadRequestError,
|
|
83
|
+
);
|
|
84
|
+
expect(() => assertSafeRelativeModelPath(123)).toThrow(BadRequestError);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("assertSafeEnvironmentPath", () => {
|
|
89
|
+
it.each([
|
|
90
|
+
"/etc/publisher",
|
|
91
|
+
"/var/lib/publisher/env1",
|
|
92
|
+
"/Users/me/data",
|
|
93
|
+
"/a",
|
|
94
|
+
"C:\\Users\\me\\publisher",
|
|
95
|
+
"C:/Users/me/publisher",
|
|
96
|
+
])("accepts %p", (p) => {
|
|
97
|
+
expect(() => assertSafeEnvironmentPath(p)).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each([
|
|
101
|
+
["empty", ""],
|
|
102
|
+
["relative", "publisher/data"],
|
|
103
|
+
["traversal in middle", "/var/lib/../../etc/passwd"],
|
|
104
|
+
["traversal at end", "/var/lib/publisher/.."],
|
|
105
|
+
["null byte", "/var/lib/publisher\0"],
|
|
106
|
+
["bare dot-dot", ".."],
|
|
107
|
+
["bare dot", "."],
|
|
108
|
+
["too long", "/" + "a".repeat(5000)],
|
|
109
|
+
])("rejects %s (%p)", (_label, p) => {
|
|
110
|
+
expect(() => assertSafeEnvironmentPath(p)).toThrow(BadRequestError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rejects non-string inputs", () => {
|
|
114
|
+
expect(() => assertSafeEnvironmentPath(undefined)).toThrow(
|
|
115
|
+
BadRequestError,
|
|
116
|
+
);
|
|
117
|
+
expect(() => assertSafeEnvironmentPath(null)).toThrow(BadRequestError);
|
|
118
|
+
expect(() => assertSafeEnvironmentPath(42)).toThrow(BadRequestError);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("safeJoinUnderRoot", () => {
|
|
123
|
+
const root = "/tmp/test-root";
|
|
124
|
+
|
|
125
|
+
it("returns the resolved root when joined with no segments", () => {
|
|
126
|
+
expect(safeJoinUnderRoot(root)).toBe(path.resolve(root));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("joins safe segments into a path under root", () => {
|
|
130
|
+
expect(safeJoinUnderRoot(root, "pkg", "model.malloy")).toBe(
|
|
131
|
+
path.resolve(root, "pkg", "model.malloy"),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("throws when traversal escapes the root", () => {
|
|
136
|
+
expect(() => safeJoinUnderRoot(root, "..")).toThrow(BadRequestError);
|
|
137
|
+
expect(() => safeJoinUnderRoot(root, "..", "etc", "passwd")).toThrow(
|
|
138
|
+
BadRequestError,
|
|
139
|
+
);
|
|
140
|
+
expect(() => safeJoinUnderRoot(root, "pkg", "..", "..", "etc")).toThrow(
|
|
141
|
+
BadRequestError,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws when an absolute segment overrides the root", () => {
|
|
146
|
+
expect(() => safeJoinUnderRoot(root, "/etc/passwd")).toThrow(
|
|
147
|
+
BadRequestError,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does NOT match a sibling directory with the same prefix", () => {
|
|
152
|
+
// path.resolve("/tmp/test-root", "../test-root-bad") -> "/tmp/test-root-bad"
|
|
153
|
+
// which starts with "/tmp/test-root" textually but is NOT a child.
|
|
154
|
+
expect(() => safeJoinUnderRoot(root, "..", "test-root-bad")).toThrow(
|
|
155
|
+
BadRequestError,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
|
|
3
|
+
import { BadRequestError } from "./errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Path-safety helpers used by `Environment` (and any other service that
|
|
7
|
+
* builds an on-disk path from request data) to defend against directory
|
|
8
|
+
* traversal. The intent is two-fold:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Source-side allowlist**: `assertSafePackageName` /
|
|
11
|
+
* `assertSafeRelativeModelPath` reject hostile inputs (`..`, leading
|
|
12
|
+
* `/`, `\`, NUL, dotfiles) at the entry of every public service
|
|
13
|
+
* method before any path-construction happens. These throw
|
|
14
|
+
* `BadRequestError` so the controller layer's error mapper returns
|
|
15
|
+
* HTTP 400.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Sink-side containment**: `safeJoinUnderRoot` joins, resolves,
|
|
18
|
+
* and verifies the result is strictly within the supplied root.
|
|
19
|
+
* Even if a future caller forgets the source-side check, the sink
|
|
20
|
+
* refuses to hand back an escaping path. This is the standard
|
|
21
|
+
* "resolve-and-contain" pattern that CodeQL's `js/path-injection`
|
|
22
|
+
* query recognises as a sanitizer.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Single path segment: ASCII letters, digits, `-`, `_`, `.`. No leading
|
|
26
|
+
// `.` so internal sibling dirs (`.staging`, `.retired`) and editor /
|
|
27
|
+
// VCS dirs can't be addressed by name from outside.
|
|
28
|
+
const SAFE_NAME_RE = /^(?!\.\.?$)(?!\.)[A-Za-z0-9._-]{1,255}$/;
|
|
29
|
+
|
|
30
|
+
const MAX_MODEL_PATH_LEN = 1024;
|
|
31
|
+
|
|
32
|
+
// An environment path is server-controlled (config / disk-derived), but
|
|
33
|
+
// CodeQL conservatively treats it as tainted because Express handlers on
|
|
34
|
+
// the same class touch user input. The combined regex test +
|
|
35
|
+
// `..` / NUL / length check at the constructor gate is the sanitizer
|
|
36
|
+
// barrier the `js/path-injection` query recognises. Printable ASCII
|
|
37
|
+
// only; absolute POSIX-or-Windows path; no `..`, no NUL.
|
|
38
|
+
const SAFE_ENVIRONMENT_PATH_RE = /^(?:\/|[A-Za-z]:[\\/])[\x20-\x7E]*$/;
|
|
39
|
+
const MAX_ENVIRONMENT_PATH_LEN = 4096;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reject anything that isn't a plausible single-segment package name.
|
|
43
|
+
* The allowlist is deliberately conservative — every existing test and
|
|
44
|
+
* production package name we've seen fits within it, and tightening
|
|
45
|
+
* here costs nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function assertSafePackageName(packageName: unknown): void {
|
|
48
|
+
if (typeof packageName !== "string" || !SAFE_NAME_RE.test(packageName)) {
|
|
49
|
+
throw new BadRequestError(
|
|
50
|
+
`Invalid package name: must be 1-255 characters of letters, digits, "-", "_", or "." and must not start with "."`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reject anything that isn't a plausible *relative* path to a model
|
|
57
|
+
* file inside a package directory. Forward slashes are allowed (models
|
|
58
|
+
* live in subdirectories like `models/foo.malloy`); backslashes,
|
|
59
|
+
* absolute paths, NUL bytes, and `..` / `.` segments are not.
|
|
60
|
+
*/
|
|
61
|
+
export function assertSafeRelativeModelPath(modelPath: unknown): void {
|
|
62
|
+
if (
|
|
63
|
+
typeof modelPath !== "string" ||
|
|
64
|
+
modelPath.length === 0 ||
|
|
65
|
+
modelPath.length > MAX_MODEL_PATH_LEN ||
|
|
66
|
+
modelPath.includes("\0") ||
|
|
67
|
+
modelPath.includes("\\") ||
|
|
68
|
+
path.isAbsolute(modelPath) ||
|
|
69
|
+
modelPath.startsWith("/")
|
|
70
|
+
) {
|
|
71
|
+
throw new BadRequestError(`Invalid model path`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const segments = modelPath.split("/");
|
|
75
|
+
for (const segment of segments) {
|
|
76
|
+
if (segment === "" || segment === "." || segment === "..") {
|
|
77
|
+
throw new BadRequestError(`Invalid model path`);
|
|
78
|
+
}
|
|
79
|
+
if (segment.startsWith(".")) {
|
|
80
|
+
throw new BadRequestError(`Invalid model path`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reject anything that doesn't look like a server-controlled absolute
|
|
87
|
+
* filesystem path. Applied to `environmentPath` at the constructor
|
|
88
|
+
* gate so all downstream `path.join(this.environmentPath, …)` sites
|
|
89
|
+
* see a value that has cleared an allowlist check — the canonical
|
|
90
|
+
* sanitizer-barrier pattern CodeQL's `js/path-injection` query
|
|
91
|
+
* recognises.
|
|
92
|
+
*/
|
|
93
|
+
export function assertSafeEnvironmentPath(environmentPath: unknown): void {
|
|
94
|
+
if (typeof environmentPath !== "string") {
|
|
95
|
+
throw new BadRequestError(`Invalid environment path: must be a string`);
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
environmentPath.length === 0 ||
|
|
99
|
+
environmentPath.length > MAX_ENVIRONMENT_PATH_LEN
|
|
100
|
+
) {
|
|
101
|
+
throw new BadRequestError(`Invalid environment path: bad length`);
|
|
102
|
+
}
|
|
103
|
+
if (environmentPath.indexOf("\0") !== -1) {
|
|
104
|
+
throw new BadRequestError(`Invalid environment path: contains NUL byte`);
|
|
105
|
+
}
|
|
106
|
+
// Sanitizer barrier in the shape `x.indexOf("..") !== -1` that the
|
|
107
|
+
// CodeQL `js/path-injection` query recognises as a traversal guard.
|
|
108
|
+
if (environmentPath.indexOf("..") !== -1) {
|
|
109
|
+
throw new BadRequestError(
|
|
110
|
+
`Invalid environment path: contains ".." traversal segment`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!SAFE_ENVIRONMENT_PATH_RE.test(environmentPath)) {
|
|
114
|
+
throw new BadRequestError(
|
|
115
|
+
`Invalid environment path: must be an absolute path of printable ASCII characters`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve `path.join(root, ...segments)` and verify the result lives
|
|
122
|
+
* strictly inside `root` (or is `root` itself). Throws
|
|
123
|
+
* `BadRequestError` if the resolved path escapes the root via `..`,
|
|
124
|
+
* absolute segments, or symlink-style trickery in the input.
|
|
125
|
+
*
|
|
126
|
+
* Callers should still run `assertSafePackageName` / similar on
|
|
127
|
+
* user-controlled segments first — this helper is the second line of
|
|
128
|
+
* defense, not the first.
|
|
129
|
+
*/
|
|
130
|
+
export function safeJoinUnderRoot(root: string, ...segments: string[]): string {
|
|
131
|
+
const resolvedRoot = path.resolve(root);
|
|
132
|
+
const joined = path.resolve(resolvedRoot, ...segments);
|
|
133
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
134
|
+
? resolvedRoot
|
|
135
|
+
: resolvedRoot + path.sep;
|
|
136
|
+
if (joined !== resolvedRoot && !joined.startsWith(rootWithSep)) {
|
|
137
|
+
throw new BadRequestError(`Resolved path is outside of root`);
|
|
138
|
+
}
|
|
139
|
+
return joined;
|
|
140
|
+
}
|