@scalar/mock-server 0.13.0 → 0.14.0
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/CHANGELOG.md +14 -0
- package/dist/utils/build-handler-context.d.ts +0 -2
- package/dist/utils/build-handler-context.d.ts.map +1 -1
- package/dist/utils/build-handler-context.js +0 -2
- package/dist/utils/build-seed-context.d.ts +5 -23
- package/dist/utils/build-seed-context.d.ts.map +1 -1
- package/dist/utils/build-seed-context.js +1 -49
- package/dist/utils/execute-handler.d.ts +5 -2
- package/dist/utils/execute-handler.d.ts.map +1 -1
- package/dist/utils/execute-handler.js +11 -13
- package/dist/utils/execute-seed.d.ts +5 -2
- package/dist/utils/execute-seed.d.ts.map +1 -1
- package/dist/utils/execute-seed.js +13 -21
- package/dist/utils/process-openapi-document.d.ts.map +1 -1
- package/dist/utils/process-openapi-document.js +8 -1
- package/dist/utils/sandbox.d.ts +25 -0
- package/dist/utils/sandbox.d.ts.map +1 -0
- package/dist/utils/sandbox.js +252 -0
- package/dist/utils/store-wrapper.d.ts +1 -1
- package/dist/utils/store-wrapper.d.ts.map +1 -1
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @scalar/mock-server
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#10082](https://github.com/scalar/scalar/pull/10082): Run `x-handler` and `x-seed` code in a real sandbox
|
|
8
|
+
|
|
9
|
+
Handler and seed code used to run with the `Function` constructor, which gave it full access to the Node.js host (`process`, `require`, and more). It now runs inside a QuickJS WebAssembly sandbox with memory and time limits, so even untrusted code from a remote or `$ref`-loaded document cannot reach the host.
|
|
10
|
+
|
|
11
|
+
The `store`, `faker`, `req`, `res`, `schema`, and `seed` APIs work as before. The one exception is faker methods that take a callback (for example `faker.helpers.multiple(fn)`), which are no longer supported because functions cannot cross the sandbox boundary.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [#10079](https://github.com/scalar/scalar/pull/10079): Harden the mock server against SSRF and local file disclosure through OpenAPI `$ref`s. External `$ref` resolution now refuses to fetch private, loopback, link-local, and metadata addresses, and confines local file reads to the document's directory. The `fetchUrls` and `readFiles` bundling plugins gain opt-in `blockPrivateNetworks` and `basePath` options, so other callers keep their current behavior unless they opt in.
|
|
16
|
+
|
|
3
17
|
## 0.13.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { faker } from '@faker-js/faker';
|
|
2
1
|
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
3
2
|
import type { Context } from 'hono';
|
|
4
3
|
import { type StoreOperationTracking, createStoreWrapper } from './store-wrapper.js';
|
|
@@ -7,7 +6,6 @@ import { type StoreOperationTracking, createStoreWrapper } from './store-wrapper
|
|
|
7
6
|
*/
|
|
8
7
|
export type HandlerContext = {
|
|
9
8
|
store: ReturnType<typeof createStoreWrapper>['wrappedStore'];
|
|
10
|
-
faker: typeof faker;
|
|
11
9
|
req: {
|
|
12
10
|
body: any;
|
|
13
11
|
params: Record<string, string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-handler-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-handler-context.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"build-handler-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-handler-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAMnC,OAAO,EAAE,KAAK,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEjF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,GAAG,EAAE;QACH,IAAI,EAAE,GAAG,CAAA;QACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAChC,CAAA;IACD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACzB,CAAA;AAED;;GAEG;AACH,KAAK,oBAAoB,GAAG;IAC1B,OAAO,EAAE,cAAc,CAAA;IACvB,QAAQ,EAAE,sBAAsB,CAAA;CACjC,CAAA;AA4DD;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,CAAC,EAAE,WAAW,CAAC,eAAe,GACtC,OAAO,CAAC,oBAAoB,CAAC,CAgD/B"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { faker } from '@faker-js/faker';
|
|
2
1
|
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
3
2
|
import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
|
|
4
3
|
import { getExampleFromSchema } from '@scalar/workspace-store/request-example';
|
|
@@ -84,7 +83,6 @@ export async function buildHandlerContext(c, operation) {
|
|
|
84
83
|
return {
|
|
85
84
|
context: {
|
|
86
85
|
store: wrappedStore,
|
|
87
|
-
faker,
|
|
88
86
|
req: {
|
|
89
87
|
body,
|
|
90
88
|
params: pathParameters(c),
|
|
@@ -1,35 +1,17 @@
|
|
|
1
|
-
import { faker } from '@faker-js/faker';
|
|
2
1
|
import { createStoreWrapper } from './store-wrapper.js';
|
|
3
|
-
/**
|
|
4
|
-
* Seed helper function type.
|
|
5
|
-
*/
|
|
6
|
-
type SeedHelper = {
|
|
7
|
-
/**
|
|
8
|
-
* Create n items using a factory function.
|
|
9
|
-
*/
|
|
10
|
-
count: (n: number, factory: () => any) => any[];
|
|
11
|
-
/**
|
|
12
|
-
* Create items from an array of objects.
|
|
13
|
-
*/
|
|
14
|
-
(items: any[]): any[];
|
|
15
|
-
/**
|
|
16
|
-
* Create a single item using a factory function (shorthand for count(1, factory)).
|
|
17
|
-
*/
|
|
18
|
-
(factory: () => any): any;
|
|
19
|
-
};
|
|
20
2
|
/**
|
|
21
3
|
* Context object provided to x-seed code.
|
|
4
|
+
*
|
|
5
|
+
* The `seed` helper itself lives inside the sandbox (see `sandbox.ts`); it
|
|
6
|
+
* persists generated items through this store using the schema key as the
|
|
7
|
+
* collection name.
|
|
22
8
|
*/
|
|
23
9
|
export type SeedContext = {
|
|
24
10
|
store: ReturnType<typeof createStoreWrapper>['wrappedStore'];
|
|
25
|
-
faker: typeof faker;
|
|
26
|
-
seed: SeedHelper;
|
|
27
11
|
schema: string;
|
|
28
12
|
};
|
|
29
13
|
/**
|
|
30
|
-
* Build the seed context
|
|
31
|
-
* The seed helper automatically uses the schema key as the collection name.
|
|
14
|
+
* Build the seed context for a schema.
|
|
32
15
|
*/
|
|
33
16
|
export declare function buildSeedContext(schemaKey: string): SeedContext;
|
|
34
|
-
export {};
|
|
35
17
|
//# sourceMappingURL=build-seed-context.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-seed-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-seed-context.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"build-seed-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-seed-context.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,WAAW,CAO/D"}
|
|
@@ -1,60 +1,12 @@
|
|
|
1
|
-
import { faker } from '@faker-js/faker';
|
|
2
1
|
import { store } from '../libs/store.js';
|
|
3
2
|
import { createStoreWrapper } from './store-wrapper.js';
|
|
4
3
|
/**
|
|
5
|
-
* Build the seed context
|
|
6
|
-
* The seed helper automatically uses the schema key as the collection name.
|
|
4
|
+
* Build the seed context for a schema.
|
|
7
5
|
*/
|
|
8
6
|
export function buildSeedContext(schemaKey) {
|
|
9
7
|
const { wrappedStore } = createStoreWrapper(store);
|
|
10
|
-
/**
|
|
11
|
-
* Seed helper function that provides a Laravel-inspired API.
|
|
12
|
-
*/
|
|
13
|
-
const seedHelper = ((arg1, arg2) => {
|
|
14
|
-
// Case 1: seed.count(n, factory)
|
|
15
|
-
if (typeof arg1 === 'number' && typeof arg2 === 'function') {
|
|
16
|
-
const count = arg1;
|
|
17
|
-
const factory = arg2;
|
|
18
|
-
const items = [];
|
|
19
|
-
for (let i = 0; i < count; i++) {
|
|
20
|
-
const item = factory();
|
|
21
|
-
const created = wrappedStore.create(schemaKey, item);
|
|
22
|
-
items.push(created);
|
|
23
|
-
}
|
|
24
|
-
return items;
|
|
25
|
-
}
|
|
26
|
-
// Case 2: seed(array)
|
|
27
|
-
if (Array.isArray(arg1)) {
|
|
28
|
-
const items = [];
|
|
29
|
-
for (const item of arg1) {
|
|
30
|
-
const created = wrappedStore.create(schemaKey, item);
|
|
31
|
-
items.push(created);
|
|
32
|
-
}
|
|
33
|
-
return items;
|
|
34
|
-
}
|
|
35
|
-
// Case 3: seed(factory) - single item
|
|
36
|
-
if (typeof arg1 === 'function') {
|
|
37
|
-
const factory = arg1;
|
|
38
|
-
const item = factory();
|
|
39
|
-
const created = wrappedStore.create(schemaKey, item);
|
|
40
|
-
return created;
|
|
41
|
-
}
|
|
42
|
-
throw new Error('Invalid seed() usage. Use seed.count(n, factory), seed(array), or seed(factory)');
|
|
43
|
-
});
|
|
44
|
-
// Add count method to the function
|
|
45
|
-
seedHelper.count = (n, factory) => {
|
|
46
|
-
const items = [];
|
|
47
|
-
for (let i = 0; i < n; i++) {
|
|
48
|
-
const item = factory();
|
|
49
|
-
const created = wrappedStore.create(schemaKey, item);
|
|
50
|
-
items.push(created);
|
|
51
|
-
}
|
|
52
|
-
return items;
|
|
53
|
-
};
|
|
54
8
|
return {
|
|
55
9
|
store: wrappedStore,
|
|
56
|
-
faker,
|
|
57
|
-
seed: seedHelper,
|
|
58
10
|
schema: schemaKey,
|
|
59
11
|
};
|
|
60
12
|
}
|
|
@@ -6,8 +6,11 @@ type HandlerExecutionResult = {
|
|
|
6
6
|
result: any;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Execute handler code
|
|
10
|
-
*
|
|
9
|
+
* Execute handler code inside the QuickJS sandbox.
|
|
10
|
+
*
|
|
11
|
+
* The handler can only reach the `store` and `faker` bridges and the injected
|
|
12
|
+
* `req`/`res` inputs. It cannot touch the host runtime, so even untrusted code
|
|
13
|
+
* from a remote or `$ref`-loaded document is safe to run.
|
|
11
14
|
*/
|
|
12
15
|
export declare function executeHandler(code: string, context: HandlerContext): Promise<HandlerExecutionResult>;
|
|
13
16
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute-handler.d.ts","sourceRoot":"","sources":["../../src/utils/execute-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"execute-handler.d.ts","sourceRoot":"","sources":["../../src/utils/execute-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAG7D;;GAEG;AACH,KAAK,sBAAsB,GAAG;IAC5B,MAAM,EAAE,GAAG,CAAA;CACZ,CAAA;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAQ3G"}
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
+
import { runInSandbox } from './sandbox.js';
|
|
1
2
|
/**
|
|
2
|
-
* Execute handler code
|
|
3
|
-
*
|
|
3
|
+
* Execute handler code inside the QuickJS sandbox.
|
|
4
|
+
*
|
|
5
|
+
* The handler can only reach the `store` and `faker` bridges and the injected
|
|
6
|
+
* `req`/`res` inputs. It cannot touch the host runtime, so even untrusted code
|
|
7
|
+
* from a remote or `$ref`-loaded document is safe to run.
|
|
4
8
|
*/
|
|
5
9
|
export async function executeHandler(code, context) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`);
|
|
12
|
-
const result = handlerFunction(context.store, context.faker, context.req, context.res);
|
|
13
|
-
// If the result is a Promise, await it
|
|
14
|
-
if (result instanceof Promise) {
|
|
15
|
-
return { result: await result };
|
|
16
|
-
}
|
|
10
|
+
const result = await runInSandbox({
|
|
11
|
+
code,
|
|
12
|
+
store: context.store,
|
|
13
|
+
jsonGlobals: { req: context.req, res: context.res },
|
|
14
|
+
});
|
|
17
15
|
return { result };
|
|
18
16
|
}
|
|
@@ -6,8 +6,11 @@ type SeedExecutionResult = {
|
|
|
6
6
|
result: any;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Execute seed code
|
|
10
|
-
*
|
|
9
|
+
* Execute seed code inside the QuickJS sandbox.
|
|
10
|
+
*
|
|
11
|
+
* The seed code can only reach the `store` and `faker` bridges and the `seed`
|
|
12
|
+
* helper, which persists generated items through the store. It cannot touch the
|
|
13
|
+
* host runtime.
|
|
11
14
|
*/
|
|
12
15
|
export declare function executeSeed(code: string, context: SeedContext): Promise<SeedExecutionResult>;
|
|
13
16
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute-seed.d.ts","sourceRoot":"","sources":["../../src/utils/execute-seed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"execute-seed.d.ts","sourceRoot":"","sources":["../../src/utils/execute-seed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGvD;;GAEG;AACH,KAAK,mBAAmB,GAAG;IACzB,MAAM,EAAE,GAAG,CAAA;CACZ,CAAA;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CASlG"}
|
|
@@ -1,25 +1,17 @@
|
|
|
1
|
+
import { runInSandbox } from './sandbox.js';
|
|
1
2
|
/**
|
|
2
|
-
* Execute seed code
|
|
3
|
-
*
|
|
3
|
+
* Execute seed code inside the QuickJS sandbox.
|
|
4
|
+
*
|
|
5
|
+
* The seed code can only reach the `store` and `faker` bridges and the `seed`
|
|
6
|
+
* helper, which persists generated items through the store. It cannot touch the
|
|
7
|
+
* host runtime.
|
|
4
8
|
*/
|
|
5
9
|
export async function executeSeed(code, context) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
const result = seedFunction(context.store, context.faker, context.seed, context.schema);
|
|
15
|
-
// If the result is a Promise, await it
|
|
16
|
-
if (result instanceof Promise) {
|
|
17
|
-
return { result: await result };
|
|
18
|
-
}
|
|
19
|
-
return { result };
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
// Re-throw to be caught by the caller
|
|
23
|
-
throw error;
|
|
24
|
-
}
|
|
10
|
+
const result = await runInSandbox({
|
|
11
|
+
code,
|
|
12
|
+
store: context.store,
|
|
13
|
+
jsonGlobals: { schema: context.schema },
|
|
14
|
+
includeSeed: true,
|
|
15
|
+
});
|
|
16
|
+
return { result };
|
|
25
17
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-openapi-document.d.ts","sourceRoot":"","sources":["../../src/utils/process-openapi-document.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-openapi-document.d.ts","sourceRoot":"","sources":["../../src/utils/process-openapi-document.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD;;;;;;;;;;;;GAYG;AACH,wBAAsB,sBAAsB,CAC1C,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GACjD,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAsD/B"}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { cwd } from 'node:process';
|
|
1
3
|
import { bundle } from '@scalar/json-magic/bundle';
|
|
2
4
|
import { fetchUrls, parseJson, parseYaml, readFiles } from '@scalar/json-magic/bundle/plugins/node';
|
|
5
|
+
import { isFilePath } from '@scalar/json-magic/helpers/is-file-path';
|
|
3
6
|
import { createMagicProxy } from '@scalar/json-magic/magic-proxy';
|
|
4
7
|
import { upgrade } from '@scalar/openapi-upgrader';
|
|
5
8
|
/**
|
|
@@ -29,11 +32,15 @@ export async function processOpenApiDocument(document) {
|
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
let bundled;
|
|
35
|
+
// Confine local file `$ref`s to the document's own directory (or the working directory when the
|
|
36
|
+
// document is an object or inline string), and refuse to fetch private or internal addresses.
|
|
37
|
+
// Without these guards a `$ref` could read arbitrary local files or reach internal services.
|
|
38
|
+
const basePath = typeof document === 'string' && isFilePath(document) ? path.dirname(path.resolve(document)) : cwd();
|
|
32
39
|
try {
|
|
33
40
|
// Bundle external references with Node.js plugins
|
|
34
41
|
// Include parseJson and parseYaml to handle string inputs
|
|
35
42
|
bundled = await bundle(document, {
|
|
36
|
-
plugins: [parseJson(), parseYaml(), readFiles(), fetchUrls()],
|
|
43
|
+
plugins: [parseJson(), parseYaml(), readFiles({ basePath }), fetchUrls({ blockPrivateNetworks: true })],
|
|
37
44
|
treeShake: false,
|
|
38
45
|
});
|
|
39
46
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StoreInterface } from './store-wrapper.js';
|
|
2
|
+
/**
|
|
3
|
+
* Options for {@link runInSandbox}.
|
|
4
|
+
*/
|
|
5
|
+
type RunInSandboxOptions = {
|
|
6
|
+
/** User-supplied handler or seed code. It runs inside an async wrapper, so top-level `return` and `await` work. */
|
|
7
|
+
code: string;
|
|
8
|
+
/** Host store bridge (already wrapped with operation tracking). */
|
|
9
|
+
store: StoreInterface;
|
|
10
|
+
/** Plain-JSON values exposed to the guest as globals (for example `req`, `res`, `schema`). */
|
|
11
|
+
jsonGlobals?: Record<string, unknown>;
|
|
12
|
+
/** When true, exposes the guest-side `seed` helper. Requires a `schema` entry in {@link jsonGlobals}. */
|
|
13
|
+
includeSeed?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Execute untrusted handler or seed code inside a QuickJS WebAssembly sandbox.
|
|
17
|
+
*
|
|
18
|
+
* The guest has no access to the host runtime (`process`, `require`, `fetch`,
|
|
19
|
+
* the `Function` constructor, and so on). It can only talk to the `store` and
|
|
20
|
+
* `faker` bridges and read the injected JSON globals. Memory and time limits
|
|
21
|
+
* bound the remaining denial-of-service risk.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runInSandbox(options: RunInSandboxOptions): Promise<unknown>;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/utils/sandbox.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAiCrD;;GAEG;AACH,KAAK,mBAAmB,GAAG;IACzB,mHAAmH;IACnH,IAAI,EAAE,MAAM,CAAA;IACZ,mEAAmE;IACnE,KAAK,EAAE,cAAc,CAAA;IACrB,8FAA8F;IAC9F,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,yGAAyG;IACzG,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB,CAAA;AA8LD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,CAAC,CAqEjF"}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
3
|
+
/**
|
|
4
|
+
* Maximum memory the sandboxed guest may allocate. Handler and seed code only
|
|
5
|
+
* shuffles small amounts of mock data around, so this is generous while still
|
|
6
|
+
* bounding a runaway allocation.
|
|
7
|
+
*/
|
|
8
|
+
const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum wall-clock time a single handler or seed run may take. This is the
|
|
11
|
+
* only defence against an infinite loop, since the guest cannot reach anything
|
|
12
|
+
* else on the host.
|
|
13
|
+
*/
|
|
14
|
+
const EXECUTION_TIMEOUT_MS = 1_000;
|
|
15
|
+
/**
|
|
16
|
+
* Property names that let code climb from an object onto its prototype chain and
|
|
17
|
+
* ultimately reach the host `Function` constructor. They are never legitimate
|
|
18
|
+
* faker module or method names, so the bridge refuses to walk through them.
|
|
19
|
+
*/
|
|
20
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
21
|
+
/** Store methods the guest bridge is allowed to call. */
|
|
22
|
+
const STORE_METHODS = new Set(['list', 'get', 'create', 'update', 'delete', 'clear']);
|
|
23
|
+
/**
|
|
24
|
+
* The QuickJS WebAssembly module is expensive to instantiate, so it is loaded
|
|
25
|
+
* once and shared. Each run still gets its own isolated context and runtime.
|
|
26
|
+
*/
|
|
27
|
+
let quickJSModule;
|
|
28
|
+
const loadQuickJS = () => (quickJSModule ??= getQuickJS());
|
|
29
|
+
/**
|
|
30
|
+
* Walk faker with a guest-provided property path and invoke the resolved method.
|
|
31
|
+
* The faker instance lives on the host; only the path and JSON arguments cross
|
|
32
|
+
* the boundary, so guest code can never obtain a reference to faker itself.
|
|
33
|
+
*/
|
|
34
|
+
function callFaker(path, args) {
|
|
35
|
+
const method = path.at(-1);
|
|
36
|
+
if (method === undefined || FORBIDDEN_KEYS.has(method)) {
|
|
37
|
+
throw new Error(`faker: "${method}" is not accessible`);
|
|
38
|
+
}
|
|
39
|
+
// Resolve everything except the last segment to the faker module owning the method.
|
|
40
|
+
let receiver = faker;
|
|
41
|
+
for (const key of path.slice(0, -1)) {
|
|
42
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
43
|
+
throw new Error(`faker: "${key}" is not accessible`);
|
|
44
|
+
}
|
|
45
|
+
receiver = receiver?.[key];
|
|
46
|
+
if (receiver === undefined || receiver === null) {
|
|
47
|
+
throw new Error(`faker: "${key}" does not exist`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fn = receiver?.[method];
|
|
51
|
+
if (typeof fn !== 'function') {
|
|
52
|
+
throw new Error(`faker: "${path.join('.')}" is not a function`);
|
|
53
|
+
}
|
|
54
|
+
// Bind to the resolved module so faker methods keep their expected `this`.
|
|
55
|
+
return fn.apply(receiver, args);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run a store method requested by the guest and return a JSON envelope.
|
|
59
|
+
*/
|
|
60
|
+
function runStoreBridge(store, method, argsJson) {
|
|
61
|
+
try {
|
|
62
|
+
if (!STORE_METHODS.has(method)) {
|
|
63
|
+
throw new Error(`store: "${method}" is not a method`);
|
|
64
|
+
}
|
|
65
|
+
const args = JSON.parse(argsJson);
|
|
66
|
+
const result = store[method](...args);
|
|
67
|
+
return serializeEnvelope({ ok: true, value: result ?? null });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return serializeEnvelope({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run a faker call requested by the guest and return a JSON envelope.
|
|
75
|
+
*/
|
|
76
|
+
function runFakerBridge(pathJson, argsJson) {
|
|
77
|
+
try {
|
|
78
|
+
const path = JSON.parse(pathJson);
|
|
79
|
+
const args = JSON.parse(argsJson);
|
|
80
|
+
return serializeEnvelope({ ok: true, value: callFaker(path, args) ?? null });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return serializeEnvelope({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Serialize a bridge envelope. Faker occasionally returns values such as `Date`
|
|
88
|
+
* that JSON cannot represent as-is; those are converted to their JSON form,
|
|
89
|
+
* which matches what handler code would send over the wire anyway.
|
|
90
|
+
*/
|
|
91
|
+
function serializeEnvelope(envelope) {
|
|
92
|
+
return JSON.stringify(envelope);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Guest-side bootstrap. It rebuilds `store` and `faker` on top of the host
|
|
96
|
+
* bridges so guest code sees the same API as before, but every call is just a
|
|
97
|
+
* JSON message to the host.
|
|
98
|
+
*/
|
|
99
|
+
const GUEST_PRELUDE = `
|
|
100
|
+
const __unwrap = (raw) => {
|
|
101
|
+
const parsed = JSON.parse(raw)
|
|
102
|
+
if (!parsed.ok) {
|
|
103
|
+
throw new Error(parsed.error)
|
|
104
|
+
}
|
|
105
|
+
return parsed.value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const store = {
|
|
109
|
+
list: (...args) => __unwrap(__store('list', JSON.stringify(args))),
|
|
110
|
+
get: (...args) => __unwrap(__store('get', JSON.stringify(args))),
|
|
111
|
+
create: (...args) => __unwrap(__store('create', JSON.stringify(args))),
|
|
112
|
+
update: (...args) => __unwrap(__store('update', JSON.stringify(args))),
|
|
113
|
+
delete: (...args) => __unwrap(__store('delete', JSON.stringify(args))),
|
|
114
|
+
clear: (...args) => __unwrap(__store('clear', JSON.stringify(args))),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const __makeFaker = (path) =>
|
|
118
|
+
new Proxy(function () {}, {
|
|
119
|
+
get: (_target, prop) => (typeof prop === 'string' ? __makeFaker(path.concat(prop)) : undefined),
|
|
120
|
+
apply: (_target, _thisArg, args) => __unwrap(__faker(JSON.stringify(path), JSON.stringify(args))),
|
|
121
|
+
})
|
|
122
|
+
const faker = __makeFaker([])
|
|
123
|
+
`;
|
|
124
|
+
/**
|
|
125
|
+
* Guest-side `seed` helper. It mirrors the Laravel-inspired API and runs the
|
|
126
|
+
* factory callbacks inside the sandbox, persisting through the `store` bridge.
|
|
127
|
+
*/
|
|
128
|
+
const SEED_PRELUDE = `
|
|
129
|
+
const seed = (() => {
|
|
130
|
+
const create = (item) => store.create(schema, item)
|
|
131
|
+
const helper = (arg1, arg2) => {
|
|
132
|
+
if (typeof arg1 === 'number' && typeof arg2 === 'function') {
|
|
133
|
+
const items = []
|
|
134
|
+
for (let index = 0; index < arg1; index++) {
|
|
135
|
+
items.push(create(arg2()))
|
|
136
|
+
}
|
|
137
|
+
return items
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(arg1)) {
|
|
140
|
+
return arg1.map(create)
|
|
141
|
+
}
|
|
142
|
+
if (typeof arg1 === 'function') {
|
|
143
|
+
return create(arg1())
|
|
144
|
+
}
|
|
145
|
+
throw new Error('Invalid seed() usage. Use seed.count(n, factory), seed(array), or seed(factory)')
|
|
146
|
+
}
|
|
147
|
+
helper.count = (n, factory) => {
|
|
148
|
+
const items = []
|
|
149
|
+
for (let index = 0; index < n; index++) {
|
|
150
|
+
items.push(create(factory()))
|
|
151
|
+
}
|
|
152
|
+
return items
|
|
153
|
+
}
|
|
154
|
+
return helper
|
|
155
|
+
})()
|
|
156
|
+
`;
|
|
157
|
+
/**
|
|
158
|
+
* Assemble the full guest program: bridges, injected JSON globals, the optional
|
|
159
|
+
* seed helper, and the user code wrapped in an async IIFE whose result becomes
|
|
160
|
+
* the completion value QuickJS hands back.
|
|
161
|
+
*/
|
|
162
|
+
function buildGuestSource(code, jsonGlobalNames, includeSeed) {
|
|
163
|
+
const jsonGlobals = jsonGlobalNames.map((name) => `const ${name} = JSON.parse(__json_${name})`).join('\n');
|
|
164
|
+
return `
|
|
165
|
+
${GUEST_PRELUDE}
|
|
166
|
+
${jsonGlobals}
|
|
167
|
+
${includeSeed ? SEED_PRELUDE : ''}
|
|
168
|
+
;(async () => {
|
|
169
|
+
${code}
|
|
170
|
+
})()
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Rebuild a host error from a dumped QuickJS error so callers see a normal
|
|
175
|
+
* `Error` instead of an opaque value.
|
|
176
|
+
*/
|
|
177
|
+
function toError(dumped) {
|
|
178
|
+
if (dumped instanceof Error) {
|
|
179
|
+
return dumped;
|
|
180
|
+
}
|
|
181
|
+
if (dumped && typeof dumped === 'object' && 'message' in dumped) {
|
|
182
|
+
const error = new Error(String(dumped.message));
|
|
183
|
+
const name = dumped.name;
|
|
184
|
+
if (typeof name === 'string') {
|
|
185
|
+
error.name = name;
|
|
186
|
+
}
|
|
187
|
+
return error;
|
|
188
|
+
}
|
|
189
|
+
return new Error(typeof dumped === 'string' ? dumped : 'Sandbox execution failed');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Execute untrusted handler or seed code inside a QuickJS WebAssembly sandbox.
|
|
193
|
+
*
|
|
194
|
+
* The guest has no access to the host runtime (`process`, `require`, `fetch`,
|
|
195
|
+
* the `Function` constructor, and so on). It can only talk to the `store` and
|
|
196
|
+
* `faker` bridges and read the injected JSON globals. Memory and time limits
|
|
197
|
+
* bound the remaining denial-of-service risk.
|
|
198
|
+
*/
|
|
199
|
+
export async function runInSandbox(options) {
|
|
200
|
+
const { code, store, jsonGlobals = {}, includeSeed = false } = options;
|
|
201
|
+
const quickJS = await loadQuickJS();
|
|
202
|
+
const context = quickJS.newContext();
|
|
203
|
+
try {
|
|
204
|
+
const { runtime } = context;
|
|
205
|
+
runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
|
|
206
|
+
const deadline = Date.now() + EXECUTION_TIMEOUT_MS;
|
|
207
|
+
runtime.setInterruptHandler(() => Date.now() > deadline);
|
|
208
|
+
// Host bridge: store operations run the real (tracked) store and return JSON.
|
|
209
|
+
const storeBridge = context.newFunction('__store', (methodHandle, argsHandle) => context.newString(runStoreBridge(store, context.getString(methodHandle), context.getString(argsHandle))));
|
|
210
|
+
context.setProp(context.global, '__store', storeBridge);
|
|
211
|
+
storeBridge.dispose();
|
|
212
|
+
// Host bridge: faker. Only a property path and JSON arguments cross the boundary.
|
|
213
|
+
const fakerBridge = context.newFunction('__faker', (pathHandle, argsHandle) => context.newString(runFakerBridge(context.getString(pathHandle), context.getString(argsHandle))));
|
|
214
|
+
context.setProp(context.global, '__faker', fakerBridge);
|
|
215
|
+
fakerBridge.dispose();
|
|
216
|
+
// Inject read-only inputs (req/res/schema) as JSON strings the guest parses.
|
|
217
|
+
for (const [name, value] of Object.entries(jsonGlobals)) {
|
|
218
|
+
const handle = context.newString(JSON.stringify(value ?? null));
|
|
219
|
+
context.setProp(context.global, `__json_${name}`, handle);
|
|
220
|
+
handle.dispose();
|
|
221
|
+
}
|
|
222
|
+
const evalResult = context.evalCode(buildGuestSource(code, Object.keys(jsonGlobals), includeSeed));
|
|
223
|
+
if (evalResult.error) {
|
|
224
|
+
const error = context.dump(evalResult.error);
|
|
225
|
+
evalResult.error.dispose();
|
|
226
|
+
throw toError(error);
|
|
227
|
+
}
|
|
228
|
+
// The completion value is the async IIFE's promise. Resolve it, then drain the
|
|
229
|
+
// job queue; every host bridge is synchronous, so one pass settles the promise.
|
|
230
|
+
const promiseHandle = evalResult.value;
|
|
231
|
+
const resolved = context.resolvePromise(promiseHandle);
|
|
232
|
+
promiseHandle.dispose();
|
|
233
|
+
const jobs = runtime.executePendingJobs();
|
|
234
|
+
if (jobs.error) {
|
|
235
|
+
const error = context.dump(jobs.error);
|
|
236
|
+
jobs.error.dispose();
|
|
237
|
+
throw toError(error);
|
|
238
|
+
}
|
|
239
|
+
const settled = await resolved;
|
|
240
|
+
if (settled.error) {
|
|
241
|
+
const error = context.dump(settled.error);
|
|
242
|
+
settled.error.dispose();
|
|
243
|
+
throw toError(error);
|
|
244
|
+
}
|
|
245
|
+
const value = context.dump(settled.value);
|
|
246
|
+
settled.value.dispose();
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
context.dispose();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { Store } from '../libs/store.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Public interface of the Store class (methods only, no private properties).
|
|
4
4
|
*/
|
|
5
|
-
type StoreInterface = Pick<Store, 'list' | 'get' | 'create' | 'update' | 'delete' | 'clear'>;
|
|
5
|
+
export type StoreInterface = Pick<Store, 'list' | 'get' | 'create' | 'update' | 'delete' | 'clear'>;
|
|
6
6
|
/**
|
|
7
7
|
* Represents a single store operation with its result.
|
|
8
8
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store-wrapper.d.ts","sourceRoot":"","sources":["../../src/utils/store-wrapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAA;AAE1C;;GAEG;AACH,
|
|
1
|
+
{"version":3,"file":"store-wrapper.d.ts","sourceRoot":"","sources":["../../src/utils/store-wrapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAA;AAE1C;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC,CAAA;AAEnG;;GAEG;AACH,KAAK,cAAc,GAAG;IACpB,iDAAiD;IACjD,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAA;IAC1D,mCAAmC;IACnC,MAAM,EAAE,GAAG,CAAA;CACZ,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,0CAA0C;IAC1C,UAAU,EAAE,cAAc,EAAE,CAAA;CAC7B,CAAA;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,KAAK,GAAG;IAChD,YAAY,EAAE,cAAc,CAAA;IAC5B,QAAQ,EAAE,sBAAsB,CAAA;CACjC,CA2CA"}
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"swagger",
|
|
17
17
|
"cli"
|
|
18
18
|
],
|
|
19
|
-
"version": "0.
|
|
19
|
+
"version": "0.14.0",
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22"
|
|
22
22
|
},
|
|
@@ -56,13 +56,14 @@
|
|
|
56
56
|
"ajv": "^8.20.0",
|
|
57
57
|
"ajv-formats": "^3.0.1",
|
|
58
58
|
"hono": "^4.12.7",
|
|
59
|
+
"quickjs-emscripten": "0.32.0",
|
|
59
60
|
"yaml": "^2.9.0",
|
|
60
|
-
"@scalar/helpers": "0.11.
|
|
61
|
-
"@scalar/json-magic": "0.13.
|
|
61
|
+
"@scalar/helpers": "0.11.3",
|
|
62
|
+
"@scalar/json-magic": "0.13.4",
|
|
62
63
|
"@scalar/openapi-types": "0.9.5",
|
|
63
64
|
"@scalar/openapi-upgrader": "0.2.15",
|
|
64
|
-
"@scalar/
|
|
65
|
-
"@scalar/
|
|
65
|
+
"@scalar/types": "0.19.0",
|
|
66
|
+
"@scalar/workspace-store": "0.60.0"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
68
69
|
"@types/node": "^24.1.0",
|