@serfab/cadre-host 0.10.0 → 0.11.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/dist/server/static.js +38 -38
- package/dist/ui/assets/index-C-iks41M.js.map +1 -1
- package/dist/ui/index.html +12 -12
- package/package.json +4 -4
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -26
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -51
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -85
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -445
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -21
- package/src/auth/self-label.ts +74 -74
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/types.ts +99 -99
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +225 -225
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -962
- package/src/donation/donation-store.ts +171 -171
- package/src/donation/donation-supervisor.ts +388 -388
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -64
- package/src/donation/types.ts +243 -243
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -40
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -91
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -259
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -263
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -233
- package/src/server/error-handler.ts +178 -178
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -23
- package/src/server/index.ts +267 -267
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -210
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -54
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -165
- package/src/strands/index.ts +14 -14
- package/src/strands/strand-service.ts +135 -135
- package/src/strands/types.ts +102 -102
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join, normalize, relative, sep } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import Fastify from 'fastify';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
-
|
|
8
|
-
import { registerStaticMount, resolveSafe } from '../static.js';
|
|
9
|
-
|
|
10
|
-
describe('resolveSafe', () => {
|
|
11
|
-
// POSIX-style fixtures; node:path normalizes separators per-platform, so the
|
|
12
|
-
// containment logic is exercised identically on Windows and POSIX.
|
|
13
|
-
const root = '/srv/app/dist/ui';
|
|
14
|
-
|
|
15
|
-
it('resolves a normal in-root asset', () => {
|
|
16
|
-
expect(resolveSafe(root, '/assets/app.js')).toBe(normalize(join(root, 'assets/app.js')));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('resolves a bare / to the root directory itself', () => {
|
|
20
|
-
expect(resolveSafe(root, '/')).toBe(normalize(root));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('resolves /index.html to the root index', () => {
|
|
24
|
-
expect(resolveSafe(root, '/index.html')).toBe(normalize(join(root, 'index.html')));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('rejects the sibling-directory prefix bypass', () => {
|
|
28
|
-
// `ui-private` shares `ui`'s basename as a prefix — the bare startsWith
|
|
29
|
-
// guard accepted this; the relative-based guard must reject it.
|
|
30
|
-
expect(resolveSafe(root, '/../ui-private/secret')).toBeNull();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('accepts an in-root asset whose name merely begins with ".."', () => {
|
|
34
|
-
// `..foo` is a legitimate descendant — the containment guard must not reject
|
|
35
|
-
// it via a loose '..' prefix match (the precise check keys on '..' + sep).
|
|
36
|
-
expect(resolveSafe(root, '/..foo.js')).toBe(normalize(join(root, '..foo.js')));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('rejects ordinary out-of-tree traversal', () => {
|
|
40
|
-
expect(resolveSafe(root, '/../../etc/passwd')).toBeNull();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('rejects an encoded out-of-tree traversal', () => {
|
|
44
|
-
expect(resolveSafe(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('rejects a NUL-byte path', () => {
|
|
48
|
-
expect(resolveSafe(root, '/app%00.js')).toBeNull();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('rejects malformed percent-encoding', () => {
|
|
52
|
-
expect(resolveSafe(root, '/%')).toBeNull();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('never returns a path that escapes the root', () => {
|
|
56
|
-
const escapes = [
|
|
57
|
-
'/../ui-private/secret',
|
|
58
|
-
'/../../etc/passwd',
|
|
59
|
-
'/../ui/../ui-private/x',
|
|
60
|
-
'/..',
|
|
61
|
-
];
|
|
62
|
-
for (const p of escapes) {
|
|
63
|
-
const resolved = resolveSafe(root, p);
|
|
64
|
-
if (resolved !== null) {
|
|
65
|
-
const rel = relative(normalize(root), resolved);
|
|
66
|
-
expect(rel === '..' || rel.startsWith('..' + sep)).toBe(false);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('serveStatic (via registerStaticMount)', () => {
|
|
73
|
-
let app: ReturnType<typeof Fastify>;
|
|
74
|
-
let tmpRoot: string;
|
|
75
|
-
let rootDir: string;
|
|
76
|
-
|
|
77
|
-
beforeEach(() => {
|
|
78
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-static-'));
|
|
79
|
-
rootDir = join(tmpRoot, 'ui');
|
|
80
|
-
mkdirSync(join(rootDir, 'assets'), { recursive: true });
|
|
81
|
-
writeFileSync(join(rootDir, 'index.html'), '<!doctype html><title>spa</title>');
|
|
82
|
-
writeFileSync(join(rootDir, 'assets', 'app.js'), 'console.log("hi");');
|
|
83
|
-
|
|
84
|
-
// Sibling directory whose name is a prefix superset of the root basename.
|
|
85
|
-
const sibling = join(tmpRoot, 'ui-private');
|
|
86
|
-
mkdirSync(sibling, { recursive: true });
|
|
87
|
-
writeFileSync(join(sibling, 'secret.txt'), 'top secret');
|
|
88
|
-
|
|
89
|
-
app = Fastify();
|
|
90
|
-
registerStaticMount(app, { rootDir });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
afterEach(async () => {
|
|
94
|
-
await app.close();
|
|
95
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('serves an in-root asset', async () => {
|
|
99
|
-
const res = await app.inject({ method: 'GET', url: '/assets/app.js' });
|
|
100
|
-
expect(res.statusCode).toBe(200);
|
|
101
|
-
expect(res.headers['content-type']).toContain('application/javascript');
|
|
102
|
-
expect(res.body).toBe('console.log("hi");');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('serves index.html for the bare root', async () => {
|
|
106
|
-
const res = await app.inject({ method: 'GET', url: '/' });
|
|
107
|
-
expect(res.statusCode).toBe(200);
|
|
108
|
-
expect(res.headers['content-type']).toContain('text/html');
|
|
109
|
-
expect(res.body).toContain('<title>spa</title>');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('serves index.html as SPA fallback for unknown HTML routes', async () => {
|
|
113
|
-
const res = await app.inject({
|
|
114
|
-
method: 'GET',
|
|
115
|
-
url: '/some/client/route',
|
|
116
|
-
headers: { accept: 'text/html' },
|
|
117
|
-
});
|
|
118
|
-
expect(res.statusCode).toBe(200);
|
|
119
|
-
expect(res.body).toContain('<title>spa</title>');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// HTTP-level regression for the sibling-prefix bypass. Two vector classes:
|
|
123
|
-
// - literal / `%2e%2e`-with-literal-slash: Fastify's router collapses the
|
|
124
|
-
// `../` to a within-root path before the handler, so these never reach
|
|
125
|
-
// `resolveSafe` in escaping form — they pass regardless of the fix.
|
|
126
|
-
// - encoded-SEPARATOR (`%2f`/`%5c`): Fastify does NOT collapse these; the
|
|
127
|
-
// handler sees the still-encoded url, `decodeSafe` then reconstitutes
|
|
128
|
-
// `../`, and the escaping path DOES reach `resolveSafe`. Under the old
|
|
129
|
-
// `startsWith` guard this served the sibling secret over HTTP — a genuine
|
|
130
|
-
// exploit. These cases fail without the `relative`-based guard.
|
|
131
|
-
// Either way the asserted invariant is the same: sibling content never leaks.
|
|
132
|
-
it('never serves sibling-prefix directory content over HTTP', async () => {
|
|
133
|
-
const vectors = [
|
|
134
|
-
'/../ui-private/secret.txt',
|
|
135
|
-
'/%2e%2e/ui-private/secret.txt',
|
|
136
|
-
'/..%2fui-private%2fsecret.txt',
|
|
137
|
-
'/%2e%2e%2fui-private%2fsecret.txt',
|
|
138
|
-
'/..%5cui-private%5csecret.txt',
|
|
139
|
-
];
|
|
140
|
-
for (const url of vectors) {
|
|
141
|
-
const res = await app.inject({ method: 'GET', url });
|
|
142
|
-
expect(res.body).not.toContain('top secret');
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
});
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, normalize, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { registerStaticMount, resolveSafe } from '../static.js';
|
|
9
|
+
|
|
10
|
+
describe('resolveSafe', () => {
|
|
11
|
+
// POSIX-style fixtures; node:path normalizes separators per-platform, so the
|
|
12
|
+
// containment logic is exercised identically on Windows and POSIX.
|
|
13
|
+
const root = '/srv/app/dist/ui';
|
|
14
|
+
|
|
15
|
+
it('resolves a normal in-root asset', () => {
|
|
16
|
+
expect(resolveSafe(root, '/assets/app.js')).toBe(normalize(join(root, 'assets/app.js')));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('resolves a bare / to the root directory itself', () => {
|
|
20
|
+
expect(resolveSafe(root, '/')).toBe(normalize(root));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves /index.html to the root index', () => {
|
|
24
|
+
expect(resolveSafe(root, '/index.html')).toBe(normalize(join(root, 'index.html')));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects the sibling-directory prefix bypass', () => {
|
|
28
|
+
// `ui-private` shares `ui`'s basename as a prefix — the bare startsWith
|
|
29
|
+
// guard accepted this; the relative-based guard must reject it.
|
|
30
|
+
expect(resolveSafe(root, '/../ui-private/secret')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts an in-root asset whose name merely begins with ".."', () => {
|
|
34
|
+
// `..foo` is a legitimate descendant — the containment guard must not reject
|
|
35
|
+
// it via a loose '..' prefix match (the precise check keys on '..' + sep).
|
|
36
|
+
expect(resolveSafe(root, '/..foo.js')).toBe(normalize(join(root, '..foo.js')));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects ordinary out-of-tree traversal', () => {
|
|
40
|
+
expect(resolveSafe(root, '/../../etc/passwd')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects an encoded out-of-tree traversal', () => {
|
|
44
|
+
expect(resolveSafe(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a NUL-byte path', () => {
|
|
48
|
+
expect(resolveSafe(root, '/app%00.js')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects malformed percent-encoding', () => {
|
|
52
|
+
expect(resolveSafe(root, '/%')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('never returns a path that escapes the root', () => {
|
|
56
|
+
const escapes = [
|
|
57
|
+
'/../ui-private/secret',
|
|
58
|
+
'/../../etc/passwd',
|
|
59
|
+
'/../ui/../ui-private/x',
|
|
60
|
+
'/..',
|
|
61
|
+
];
|
|
62
|
+
for (const p of escapes) {
|
|
63
|
+
const resolved = resolveSafe(root, p);
|
|
64
|
+
if (resolved !== null) {
|
|
65
|
+
const rel = relative(normalize(root), resolved);
|
|
66
|
+
expect(rel === '..' || rel.startsWith('..' + sep)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('serveStatic (via registerStaticMount)', () => {
|
|
73
|
+
let app: ReturnType<typeof Fastify>;
|
|
74
|
+
let tmpRoot: string;
|
|
75
|
+
let rootDir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-static-'));
|
|
79
|
+
rootDir = join(tmpRoot, 'ui');
|
|
80
|
+
mkdirSync(join(rootDir, 'assets'), { recursive: true });
|
|
81
|
+
writeFileSync(join(rootDir, 'index.html'), '<!doctype html><title>spa</title>');
|
|
82
|
+
writeFileSync(join(rootDir, 'assets', 'app.js'), 'console.log("hi");');
|
|
83
|
+
|
|
84
|
+
// Sibling directory whose name is a prefix superset of the root basename.
|
|
85
|
+
const sibling = join(tmpRoot, 'ui-private');
|
|
86
|
+
mkdirSync(sibling, { recursive: true });
|
|
87
|
+
writeFileSync(join(sibling, 'secret.txt'), 'top secret');
|
|
88
|
+
|
|
89
|
+
app = Fastify();
|
|
90
|
+
registerStaticMount(app, { rootDir });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await app.close();
|
|
95
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('serves an in-root asset', async () => {
|
|
99
|
+
const res = await app.inject({ method: 'GET', url: '/assets/app.js' });
|
|
100
|
+
expect(res.statusCode).toBe(200);
|
|
101
|
+
expect(res.headers['content-type']).toContain('application/javascript');
|
|
102
|
+
expect(res.body).toBe('console.log("hi");');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('serves index.html for the bare root', async () => {
|
|
106
|
+
const res = await app.inject({ method: 'GET', url: '/' });
|
|
107
|
+
expect(res.statusCode).toBe(200);
|
|
108
|
+
expect(res.headers['content-type']).toContain('text/html');
|
|
109
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('serves index.html as SPA fallback for unknown HTML routes', async () => {
|
|
113
|
+
const res = await app.inject({
|
|
114
|
+
method: 'GET',
|
|
115
|
+
url: '/some/client/route',
|
|
116
|
+
headers: { accept: 'text/html' },
|
|
117
|
+
});
|
|
118
|
+
expect(res.statusCode).toBe(200);
|
|
119
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// HTTP-level regression for the sibling-prefix bypass. Two vector classes:
|
|
123
|
+
// - literal / `%2e%2e`-with-literal-slash: Fastify's router collapses the
|
|
124
|
+
// `../` to a within-root path before the handler, so these never reach
|
|
125
|
+
// `resolveSafe` in escaping form — they pass regardless of the fix.
|
|
126
|
+
// - encoded-SEPARATOR (`%2f`/`%5c`): Fastify does NOT collapse these; the
|
|
127
|
+
// handler sees the still-encoded url, `decodeSafe` then reconstitutes
|
|
128
|
+
// `../`, and the escaping path DOES reach `resolveSafe`. Under the old
|
|
129
|
+
// `startsWith` guard this served the sibling secret over HTTP — a genuine
|
|
130
|
+
// exploit. These cases fail without the `relative`-based guard.
|
|
131
|
+
// Either way the asserted invariant is the same: sibling content never leaks.
|
|
132
|
+
it('never serves sibling-prefix directory content over HTTP', async () => {
|
|
133
|
+
const vectors = [
|
|
134
|
+
'/../ui-private/secret.txt',
|
|
135
|
+
'/%2e%2e/ui-private/secret.txt',
|
|
136
|
+
'/..%2fui-private%2fsecret.txt',
|
|
137
|
+
'/%2e%2e%2fui-private%2fsecret.txt',
|
|
138
|
+
'/..%5cui-private%5csecret.txt',
|
|
139
|
+
];
|
|
140
|
+
for (const url of vectors) {
|
|
141
|
+
const res = await app.inject({ method: 'GET', url });
|
|
142
|
+
expect(res.body).not.toContain('top secret');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
import Fastify from 'fastify';
|
|
2
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
3
|
-
|
|
4
|
-
import { registerErrorHandler } from '../error-handler.js';
|
|
5
|
-
import { registerStatusRoute } from '../routes/status.js';
|
|
6
|
-
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
7
|
-
import type { ManagedNodeInfo } from '../../orchestrator/types.js';
|
|
8
|
-
import type { TrustCircleService } from '../../auth/index.js';
|
|
9
|
-
import type { TrustCircleSnapshot } from '../../auth/types.js';
|
|
10
|
-
import type { NatService } from '../../nat/index.js';
|
|
11
|
-
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
12
|
-
import type { UpdateService } from '../../update/index.js';
|
|
13
|
-
import type { UpdateState } from '../../update/types.js';
|
|
14
|
-
|
|
15
|
-
function fakeOrchestrator(nodes: ManagedNodeInfo[]): HostProcessOrchestrator {
|
|
16
|
-
return { listNodes: () => nodes } as unknown as HostProcessOrchestrator;
|
|
17
|
-
}
|
|
18
|
-
function fakeTrustCircle(snap: TrustCircleSnapshot): TrustCircleService {
|
|
19
|
-
return { list: async () => snap } as unknown as TrustCircleService;
|
|
20
|
-
}
|
|
21
|
-
function fakeNat(snap: NatStatusSnapshot): NatService {
|
|
22
|
-
return { getStatus: () => snap } as unknown as NatService;
|
|
23
|
-
}
|
|
24
|
-
function fakeUpdate(state: UpdateState): UpdateService {
|
|
25
|
-
return { getState: async () => state } as unknown as UpdateService;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
|
|
29
|
-
portMode: 'auto-upnp',
|
|
30
|
-
externalPort: 4001,
|
|
31
|
-
internalPort: 4001,
|
|
32
|
-
routerExternalIp: '203.0.113.5',
|
|
33
|
-
mappingLeaseExpiresAt: null,
|
|
34
|
-
externalIp: '203.0.113.5',
|
|
35
|
-
externalIpDetectedAt: null,
|
|
36
|
-
cgnatDetected: false,
|
|
37
|
-
directReachability: 'reachable',
|
|
38
|
-
lastTestedAt: null,
|
|
39
|
-
ddns: {
|
|
40
|
-
providerId: null,
|
|
41
|
-
hostname: null,
|
|
42
|
-
externallyManaged: false,
|
|
43
|
-
lastUpdateAt: null,
|
|
44
|
-
lastUpdateOk: null,
|
|
45
|
-
lastError: null,
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
describe('GET /api/status', () => {
|
|
50
|
-
let app: ReturnType<typeof Fastify>;
|
|
51
|
-
|
|
52
|
-
afterEach(async () => { await app.close(); });
|
|
53
|
-
|
|
54
|
-
it('returns aggregated status without update service', async () => {
|
|
55
|
-
app = Fastify();
|
|
56
|
-
registerErrorHandler(app);
|
|
57
|
-
registerStatusRoute(app, {
|
|
58
|
-
orchestrator: fakeOrchestrator([
|
|
59
|
-
{
|
|
60
|
-
id: 'alice',
|
|
61
|
-
dockerId: '1234:abc',
|
|
62
|
-
partyId: 'party-alice',
|
|
63
|
-
profile: 'storage',
|
|
64
|
-
status: 'running',
|
|
65
|
-
spawnedAt: '2025-01-01T00:00:00Z',
|
|
66
|
-
workdir: '/tmp/alice',
|
|
67
|
-
ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
|
|
68
|
-
},
|
|
69
|
-
]),
|
|
70
|
-
trustCircle: fakeTrustCircle({
|
|
71
|
-
members: [{ peerId: 'p1', label: 'Self', addedAt: '2025-01-01T00:00:00Z', self: true }],
|
|
72
|
-
pending: [],
|
|
73
|
-
}),
|
|
74
|
-
nat: fakeNat(SAMPLE_CONNECTIVITY),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
78
|
-
expect(res.statusCode).toBe(200);
|
|
79
|
-
const body = res.json() as {
|
|
80
|
-
service: { name: string; version: string; uptimeSeconds: number };
|
|
81
|
-
nodes: Array<{ id: string; status: string }>;
|
|
82
|
-
trustCircle: { members: number; pending: number };
|
|
83
|
-
connectivity: { portMode: string };
|
|
84
|
-
update?: { available?: string };
|
|
85
|
-
};
|
|
86
|
-
expect(body.service.name).toBe('cadre-host');
|
|
87
|
-
expect(body.nodes).toHaveLength(1);
|
|
88
|
-
expect(body.nodes[0]).toMatchObject({ id: 'alice', status: 'running' });
|
|
89
|
-
expect(body.trustCircle).toEqual({ members: 1, pending: 0 });
|
|
90
|
-
expect(body.connectivity.portMode).toBe('auto-upnp');
|
|
91
|
-
expect(body.update).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
|
|
95
|
-
app = Fastify();
|
|
96
|
-
registerErrorHandler(app);
|
|
97
|
-
registerStatusRoute(app, {
|
|
98
|
-
orchestrator: fakeOrchestrator([]),
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
102
|
-
expect(res.statusCode).toBe(200);
|
|
103
|
-
const body = res.json() as {
|
|
104
|
-
service: { name: string };
|
|
105
|
-
nodes: unknown[];
|
|
106
|
-
trustCircle?: unknown;
|
|
107
|
-
connectivity?: unknown;
|
|
108
|
-
};
|
|
109
|
-
expect(body.service.name).toBe('cadre-host');
|
|
110
|
-
expect(body.nodes).toEqual([]);
|
|
111
|
-
expect(body.trustCircle).toBeUndefined();
|
|
112
|
-
expect(body.connectivity).toBeUndefined();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('includes update info when service is present', async () => {
|
|
116
|
-
app = Fastify();
|
|
117
|
-
registerErrorHandler(app);
|
|
118
|
-
registerStatusRoute(app, {
|
|
119
|
-
orchestrator: fakeOrchestrator([]),
|
|
120
|
-
trustCircle: fakeTrustCircle({ members: [], pending: [] }),
|
|
121
|
-
nat: fakeNat(SAMPLE_CONNECTIVITY),
|
|
122
|
-
update: fakeUpdate({
|
|
123
|
-
version: 1,
|
|
124
|
-
lastChecked: '2025-06-01T00:00:00Z',
|
|
125
|
-
available: { version: '0.7.0', publishedAt: '2025-06-01T00:00:00Z' },
|
|
126
|
-
}),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
130
|
-
expect(res.statusCode).toBe(200);
|
|
131
|
-
const body = res.json() as { update?: { available?: string; lastChecked?: string } };
|
|
132
|
-
expect(body.update).toEqual({ available: '0.7.0', lastChecked: '2025-06-01T00:00:00Z' });
|
|
133
|
-
});
|
|
134
|
-
});
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
5
|
+
import { registerStatusRoute } from '../routes/status.js';
|
|
6
|
+
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
7
|
+
import type { ManagedNodeInfo } from '../../orchestrator/types.js';
|
|
8
|
+
import type { TrustCircleService } from '../../auth/index.js';
|
|
9
|
+
import type { TrustCircleSnapshot } from '../../auth/types.js';
|
|
10
|
+
import type { NatService } from '../../nat/index.js';
|
|
11
|
+
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
12
|
+
import type { UpdateService } from '../../update/index.js';
|
|
13
|
+
import type { UpdateState } from '../../update/types.js';
|
|
14
|
+
|
|
15
|
+
function fakeOrchestrator(nodes: ManagedNodeInfo[]): HostProcessOrchestrator {
|
|
16
|
+
return { listNodes: () => nodes } as unknown as HostProcessOrchestrator;
|
|
17
|
+
}
|
|
18
|
+
function fakeTrustCircle(snap: TrustCircleSnapshot): TrustCircleService {
|
|
19
|
+
return { list: async () => snap } as unknown as TrustCircleService;
|
|
20
|
+
}
|
|
21
|
+
function fakeNat(snap: NatStatusSnapshot): NatService {
|
|
22
|
+
return { getStatus: () => snap } as unknown as NatService;
|
|
23
|
+
}
|
|
24
|
+
function fakeUpdate(state: UpdateState): UpdateService {
|
|
25
|
+
return { getState: async () => state } as unknown as UpdateService;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
|
|
29
|
+
portMode: 'auto-upnp',
|
|
30
|
+
externalPort: 4001,
|
|
31
|
+
internalPort: 4001,
|
|
32
|
+
routerExternalIp: '203.0.113.5',
|
|
33
|
+
mappingLeaseExpiresAt: null,
|
|
34
|
+
externalIp: '203.0.113.5',
|
|
35
|
+
externalIpDetectedAt: null,
|
|
36
|
+
cgnatDetected: false,
|
|
37
|
+
directReachability: 'reachable',
|
|
38
|
+
lastTestedAt: null,
|
|
39
|
+
ddns: {
|
|
40
|
+
providerId: null,
|
|
41
|
+
hostname: null,
|
|
42
|
+
externallyManaged: false,
|
|
43
|
+
lastUpdateAt: null,
|
|
44
|
+
lastUpdateOk: null,
|
|
45
|
+
lastError: null,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe('GET /api/status', () => {
|
|
50
|
+
let app: ReturnType<typeof Fastify>;
|
|
51
|
+
|
|
52
|
+
afterEach(async () => { await app.close(); });
|
|
53
|
+
|
|
54
|
+
it('returns aggregated status without update service', async () => {
|
|
55
|
+
app = Fastify();
|
|
56
|
+
registerErrorHandler(app);
|
|
57
|
+
registerStatusRoute(app, {
|
|
58
|
+
orchestrator: fakeOrchestrator([
|
|
59
|
+
{
|
|
60
|
+
id: 'alice',
|
|
61
|
+
dockerId: '1234:abc',
|
|
62
|
+
partyId: 'party-alice',
|
|
63
|
+
profile: 'storage',
|
|
64
|
+
status: 'running',
|
|
65
|
+
spawnedAt: '2025-01-01T00:00:00Z',
|
|
66
|
+
workdir: '/tmp/alice',
|
|
67
|
+
ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
|
|
68
|
+
},
|
|
69
|
+
]),
|
|
70
|
+
trustCircle: fakeTrustCircle({
|
|
71
|
+
members: [{ peerId: 'p1', label: 'Self', addedAt: '2025-01-01T00:00:00Z', self: true }],
|
|
72
|
+
pending: [],
|
|
73
|
+
}),
|
|
74
|
+
nat: fakeNat(SAMPLE_CONNECTIVITY),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
78
|
+
expect(res.statusCode).toBe(200);
|
|
79
|
+
const body = res.json() as {
|
|
80
|
+
service: { name: string; version: string; uptimeSeconds: number };
|
|
81
|
+
nodes: Array<{ id: string; status: string }>;
|
|
82
|
+
trustCircle: { members: number; pending: number };
|
|
83
|
+
connectivity: { portMode: string };
|
|
84
|
+
update?: { available?: string };
|
|
85
|
+
};
|
|
86
|
+
expect(body.service.name).toBe('cadre-host');
|
|
87
|
+
expect(body.nodes).toHaveLength(1);
|
|
88
|
+
expect(body.nodes[0]).toMatchObject({ id: 'alice', status: 'running' });
|
|
89
|
+
expect(body.trustCircle).toEqual({ members: 1, pending: 0 });
|
|
90
|
+
expect(body.connectivity.portMode).toBe('auto-upnp');
|
|
91
|
+
expect(body.update).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
|
|
95
|
+
app = Fastify();
|
|
96
|
+
registerErrorHandler(app);
|
|
97
|
+
registerStatusRoute(app, {
|
|
98
|
+
orchestrator: fakeOrchestrator([]),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
102
|
+
expect(res.statusCode).toBe(200);
|
|
103
|
+
const body = res.json() as {
|
|
104
|
+
service: { name: string };
|
|
105
|
+
nodes: unknown[];
|
|
106
|
+
trustCircle?: unknown;
|
|
107
|
+
connectivity?: unknown;
|
|
108
|
+
};
|
|
109
|
+
expect(body.service.name).toBe('cadre-host');
|
|
110
|
+
expect(body.nodes).toEqual([]);
|
|
111
|
+
expect(body.trustCircle).toBeUndefined();
|
|
112
|
+
expect(body.connectivity).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('includes update info when service is present', async () => {
|
|
116
|
+
app = Fastify();
|
|
117
|
+
registerErrorHandler(app);
|
|
118
|
+
registerStatusRoute(app, {
|
|
119
|
+
orchestrator: fakeOrchestrator([]),
|
|
120
|
+
trustCircle: fakeTrustCircle({ members: [], pending: [] }),
|
|
121
|
+
nat: fakeNat(SAMPLE_CONNECTIVITY),
|
|
122
|
+
update: fakeUpdate({
|
|
123
|
+
version: 1,
|
|
124
|
+
lastChecked: '2025-06-01T00:00:00Z',
|
|
125
|
+
available: { version: '0.7.0', publishedAt: '2025-06-01T00:00:00Z' },
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
130
|
+
expect(res.statusCode).toBe(200);
|
|
131
|
+
const body = res.json() as { update?: { available?: string; lastChecked?: string } };
|
|
132
|
+
expect(body.update).toEqual({ available: '0.7.0', lastChecked: '2025-06-01T00:00:00Z' });
|
|
133
|
+
});
|
|
134
|
+
});
|