@themoltnet/sandbox-gondolin 0.4.0 → 0.5.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/README.md +12 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +113 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,18 @@ queries. Gondolin also decodes, substitutes, and re-encodes HTTP Basic
|
|
|
87
87
|
authorization, covering the password encoding used by HTTPS Git credential
|
|
88
88
|
helpers; OAuth client secrets sent in form bodies remain host-only.
|
|
89
89
|
|
|
90
|
+
`vm.network.policy_bound` reports the complete hostname inputs passed to
|
|
91
|
+
Gondolin; it is configuration evidence, not independent proof of Gondolin's
|
|
92
|
+
resolved state. Brokered requests emit `vm.network.origin_checked` for
|
|
93
|
+
canonical protocol/hostname/port decisions at request and IP phases. Denials
|
|
94
|
+
remain available as `vm.network.origin_denied`. All diagnostics are
|
|
95
|
+
value-free.
|
|
96
|
+
|
|
97
|
+
Gondolin 0.12 pins the actual upstream connection to the policy-checked IP only
|
|
98
|
+
when using its default fetch. This package therefore does not expose a custom
|
|
99
|
+
fetch or fixture-routing seam: doing so would retain a preflight IP check while
|
|
100
|
+
losing connect-time pinning and reopening a DNS-rebinding window.
|
|
101
|
+
|
|
90
102
|
Rotation and revocation do not require exposing or changing the guest
|
|
91
103
|
placeholder:
|
|
92
104
|
|
package/dist/index.d.ts
CHANGED
|
@@ -91,8 +91,9 @@ export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: Bro
|
|
|
91
91
|
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
92
92
|
*/
|
|
93
93
|
export declare function createBrokeredHttpNetworkOriginPolicy(bindings: readonly BrokeredHttpSecretDescriptor[], options?: {
|
|
94
|
-
|
|
94
|
+
onDecision?: (decision: RequestOrigin & {
|
|
95
95
|
phase: 'request' | 'ip';
|
|
96
|
+
allowed: boolean;
|
|
96
97
|
}) => void;
|
|
97
98
|
}): {
|
|
98
99
|
isRequestAllowed: (request: Request) => boolean;
|
|
@@ -461,7 +462,7 @@ export declare interface VmCredentials {
|
|
|
461
462
|
}
|
|
462
463
|
|
|
463
464
|
export declare interface VmDiagnostic {
|
|
464
|
-
event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.origin_denied';
|
|
465
|
+
event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.policy_bound' | 'vm.network.origin_checked' | 'vm.network.origin_denied';
|
|
465
466
|
level: 'info' | 'warning';
|
|
466
467
|
message: string;
|
|
467
468
|
/** Present only for the value-free broker summary event. */
|
|
@@ -471,12 +472,18 @@ export declare interface VmDiagnostic {
|
|
|
471
472
|
/** Present only for the guest-projection summary event. */
|
|
472
473
|
projectedFileCount?: number;
|
|
473
474
|
projectedServiceCount?: number;
|
|
474
|
-
/**
|
|
475
|
+
/** Complete value-free hostname inputs passed to Gondolin. */
|
|
476
|
+
hostnamePolicy?: {
|
|
477
|
+
allowedHosts: readonly string[];
|
|
478
|
+
allowedInternalHosts: readonly string[];
|
|
479
|
+
};
|
|
480
|
+
/** Canonical, value-free decision for an origin check or denial. */
|
|
475
481
|
origin?: {
|
|
476
482
|
hostname: string;
|
|
477
483
|
protocol: string;
|
|
478
484
|
port: number;
|
|
479
485
|
phase: 'request' | 'ip';
|
|
486
|
+
allowed: boolean;
|
|
480
487
|
};
|
|
481
488
|
}
|
|
482
489
|
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
5
5
|
import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, isWriteFlag, loadGuestAssets } from "@earendil-works/gondolin";
|
|
6
6
|
import { isIP } from "node:net";
|
|
7
|
+
import { domainToASCII } from "node:url";
|
|
7
8
|
//#region src/abort-utils.ts
|
|
8
9
|
function throwIfAborted(signal, label) {
|
|
9
10
|
if (!signal?.aborted) return;
|
|
@@ -425,6 +426,76 @@ function pruneOldSnapshots(maxCached, currentDir) {
|
|
|
425
426
|
});
|
|
426
427
|
}
|
|
427
428
|
//#endregion
|
|
429
|
+
//#region src/canonical-host.ts
|
|
430
|
+
var DECIMAL_IPV4_COMPONENTS = /^\d+(?:\.\d+){0,3}$/;
|
|
431
|
+
var NON_DECIMAL_IPV4 = /^(?:0x[\da-f]+|0[0-7]+)(?:\.(?:0x[\da-f]+|0[0-7]+|\d+)){0,3}$/i;
|
|
432
|
+
function stripTrailingDots(value) {
|
|
433
|
+
let end = value.length;
|
|
434
|
+
while (end > 0 && value.charCodeAt(end - 1) === 46) end -= 1;
|
|
435
|
+
return value.slice(0, end);
|
|
436
|
+
}
|
|
437
|
+
function rejectAlternateNumericAddress(hostname) {
|
|
438
|
+
if (isIP(hostname) === 0 && (DECIMAL_IPV4_COMPONENTS.test(hostname) || NON_DECIMAL_IPV4.test(hostname) || /^\d+$/.test(hostname))) throw new Error("alternate numeric IP forms are not admitted");
|
|
439
|
+
}
|
|
440
|
+
/** Canonicalize one concrete DNS name or IP literal for policy comparison. */
|
|
441
|
+
function canonicalizeHostname(input) {
|
|
442
|
+
const withoutTrailingDot = stripTrailingDots(input.trim().replace(/^\[|\]$/g, ""));
|
|
443
|
+
if (!withoutTrailingDot) throw new Error("hostname is required");
|
|
444
|
+
rejectAlternateNumericAddress(withoutTrailingDot);
|
|
445
|
+
const ipVersion = isIP(withoutTrailingDot);
|
|
446
|
+
if (ipVersion === 4) return withoutTrailingDot;
|
|
447
|
+
if (ipVersion === 6) return new URL(`http://[${withoutTrailingDot}]/`).hostname.slice(1, -1);
|
|
448
|
+
if (/[\s/:@?#[\]]/.test(withoutTrailingDot)) throw new Error("invalid hostname");
|
|
449
|
+
const ascii = domainToASCII(withoutTrailingDot.toLowerCase());
|
|
450
|
+
if (!ascii || ascii.length > 253) throw new Error("invalid hostname");
|
|
451
|
+
return ascii;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Credential destinations deliberately support only exact hosts, a global
|
|
455
|
+
* wildcard, or a leading one-label wildcard. Arbitrary globs are too
|
|
456
|
+
* ambiguous for a secret-delivery boundary.
|
|
457
|
+
*/
|
|
458
|
+
function canonicalizeCredentialHostPattern(input) {
|
|
459
|
+
const pattern = input.trim();
|
|
460
|
+
if (pattern === "*") return pattern;
|
|
461
|
+
if (pattern.startsWith("*.") && pattern.indexOf("*", 1) === -1) return `*.${canonicalizeHostname(pattern.slice(2))}`;
|
|
462
|
+
if (pattern.includes("*")) throw new Error("credential host patterns allow only a leading *.");
|
|
463
|
+
return canonicalizeHostname(pattern);
|
|
464
|
+
}
|
|
465
|
+
function credentialHostMatches(hostnameInput, patternInput) {
|
|
466
|
+
const hostname = canonicalizeHostname(hostnameInput);
|
|
467
|
+
const pattern = canonicalizeCredentialHostPattern(patternInput);
|
|
468
|
+
if (pattern === "*") return true;
|
|
469
|
+
if (!pattern.startsWith("*.")) return hostname === pattern;
|
|
470
|
+
const suffix = pattern.slice(2);
|
|
471
|
+
if (!hostname.endsWith(`.${suffix}`)) return false;
|
|
472
|
+
const prefix = hostname.slice(0, -(suffix.length + 1));
|
|
473
|
+
return prefix.length > 0 && !prefix.includes(".");
|
|
474
|
+
}
|
|
475
|
+
/** Keep Gondolin's wider network glob syntax separate from secret patterns. */
|
|
476
|
+
function normalizeNetworkHostPattern(input) {
|
|
477
|
+
const pattern = input.trim();
|
|
478
|
+
if (pattern === "*") return pattern;
|
|
479
|
+
if (!pattern.includes("*")) return canonicalizeHostname(pattern);
|
|
480
|
+
const normalized = stripTrailingDots(pattern.toLowerCase());
|
|
481
|
+
if (normalized === "" || normalized.includes("://") || normalized.includes("/") || normalized.includes(":") || /\s/.test(normalized)) throw new Error("invalid network host pattern");
|
|
482
|
+
return normalized;
|
|
483
|
+
}
|
|
484
|
+
function networkPatternMatchesHostname(networkPatternInput, hostnameInput) {
|
|
485
|
+
const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
|
|
486
|
+
const hostname = canonicalizeHostname(hostnameInput);
|
|
487
|
+
const expression = networkPattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
488
|
+
return new RegExp(`^${expression}$`, "i").test(hostname);
|
|
489
|
+
}
|
|
490
|
+
/** Prove that a network grant covers a narrower credential destination. */
|
|
491
|
+
function networkPatternCoversCredentialPattern(networkPatternInput, credentialPatternInput) {
|
|
492
|
+
const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
|
|
493
|
+
const credentialPattern = canonicalizeCredentialHostPattern(credentialPatternInput);
|
|
494
|
+
if (networkPattern === "*") return true;
|
|
495
|
+
if (credentialPattern.includes("*")) return networkPattern === credentialPattern;
|
|
496
|
+
return networkPatternMatchesHostname(networkPattern, credentialPattern);
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
428
499
|
//#region src/host-origins.ts
|
|
429
500
|
function hostOriginHostnames(origins) {
|
|
430
501
|
return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
|
|
@@ -660,16 +731,6 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
|
|
|
660
731
|
"constructor",
|
|
661
732
|
"prototype"
|
|
662
733
|
]);
|
|
663
|
-
function normalizeHostPattern(pattern) {
|
|
664
|
-
let normalized = pattern.trim().toLowerCase();
|
|
665
|
-
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
|
|
666
|
-
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
|
|
667
|
-
return normalized;
|
|
668
|
-
}
|
|
669
|
-
function isValidHostPattern(pattern) {
|
|
670
|
-
if (isIP(pattern) !== 0) return true;
|
|
671
|
-
return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
|
|
672
|
-
}
|
|
673
734
|
/**
|
|
674
735
|
* Canonicalize and validate the value-free portion of a brokered binding.
|
|
675
736
|
* Runtime attestation and Gondolin enforcement both consume this function so
|
|
@@ -679,15 +740,20 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
679
740
|
const issues = [];
|
|
680
741
|
const id = descriptor.id.trim();
|
|
681
742
|
const guestEnv = descriptor.guestEnv.trim();
|
|
682
|
-
const hosts = [
|
|
743
|
+
const hosts = [];
|
|
683
744
|
const protocolInput = descriptor.protocol ?? "https";
|
|
684
745
|
const protocol = protocolInput === "http" ? "http" : "https";
|
|
685
746
|
const ports = [...new Set(descriptor.ports ?? [protocol === "https" ? 443 : 80])].sort((left, right) => left - right);
|
|
686
747
|
if (!BROKERED_SECRET_ID_REGEXP.test(id)) issues.push(`invalid requirement id "${id || "<empty>"}"`);
|
|
687
748
|
if (!GUEST_ENV_NAME_REGEXP.test(guestEnv)) issues.push(`requirement "${id}" has invalid guest env "${guestEnv}"`);
|
|
688
749
|
else if (isReservedGuestEnvironmentName(guestEnv) || OBJECT_META_PROPERTY_NAMES.has(guestEnv)) issues.push(`requirement "${id}" uses reserved guest env "${guestEnv}"`);
|
|
689
|
-
|
|
690
|
-
|
|
750
|
+
for (const hostInput of descriptor.hosts) try {
|
|
751
|
+
hosts.push(canonicalizeCredentialHostPattern(hostInput));
|
|
752
|
+
} catch {
|
|
753
|
+
issues.push(`requirement "${id}" has invalid host pattern "${hostInput.trim()}"`);
|
|
754
|
+
}
|
|
755
|
+
const uniqueHosts = [...new Set(hosts)].sort();
|
|
756
|
+
if (descriptor.hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
|
|
691
757
|
if (protocolInput !== "https" && protocolInput !== "http") issues.push(`requirement "${id}" has invalid protocol "${String(protocolInput)}"`);
|
|
692
758
|
if (ports.length === 0) issues.push(`requirement "${id}" has no destination ports`);
|
|
693
759
|
for (const port of ports) if (!Number.isInteger(port) || port < 1 || port > 65535) issues.push(`requirement "${id}" has invalid port "${port}"`);
|
|
@@ -695,16 +761,12 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
695
761
|
return Object.freeze({
|
|
696
762
|
id,
|
|
697
763
|
guestEnv,
|
|
698
|
-
hosts: Object.freeze(
|
|
764
|
+
hosts: Object.freeze(uniqueHosts),
|
|
699
765
|
protocol,
|
|
700
766
|
ports: Object.freeze(ports),
|
|
701
767
|
required: descriptor.required !== false
|
|
702
768
|
});
|
|
703
769
|
}
|
|
704
|
-
function hostMatchesPattern(hostname, pattern) {
|
|
705
|
-
const expression = normalizeHostPattern(pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
706
|
-
return new RegExp(`^${expression}$`, "i").test(normalizeHostPattern(hostname));
|
|
707
|
-
}
|
|
708
770
|
/**
|
|
709
771
|
* Conservative subset check for Gondolin hostname globs. Exact secret hosts
|
|
710
772
|
* may sit below a broader network wildcard. A wildcard secret destination must
|
|
@@ -712,9 +774,7 @@ function hostMatchesPattern(hostname, pattern) {
|
|
|
712
774
|
* through an ambiguous glob comparison.
|
|
713
775
|
*/
|
|
714
776
|
function networkPatternCoversSecretPattern(networkPattern, secretPattern) {
|
|
715
|
-
|
|
716
|
-
if (secretPattern.includes("*")) return networkPattern === secretPattern;
|
|
717
|
-
return hostMatchesPattern(secretPattern, networkPattern);
|
|
777
|
+
return networkPatternCoversCredentialPattern(networkPattern, secretPattern);
|
|
718
778
|
}
|
|
719
779
|
/**
|
|
720
780
|
* Validate value-free descriptors, host-local bindings, environment
|
|
@@ -725,7 +785,7 @@ function prepareBrokeredHttpSecrets(options) {
|
|
|
725
785
|
const ids = /* @__PURE__ */ new Set();
|
|
726
786
|
const guestEnvNames = /* @__PURE__ */ new Set();
|
|
727
787
|
const occupiedGuestEnvNames = new Set(options.occupiedGuestEnvNames ?? []);
|
|
728
|
-
const allowedHosts = [...new Set(options.allowedHosts.map(
|
|
788
|
+
const allowedHosts = [...new Set(options.allowedHosts.map(normalizeNetworkHostPattern))];
|
|
729
789
|
const secrets = Object.create(null);
|
|
730
790
|
for (const binding of options.bindings ?? []) {
|
|
731
791
|
let descriptor;
|
|
@@ -780,7 +840,7 @@ function parseRequestOrigin(url) {
|
|
|
780
840
|
const parsed = new URL(url);
|
|
781
841
|
const protocol = parsed.protocol.slice(0, -1);
|
|
782
842
|
return {
|
|
783
|
-
hostname:
|
|
843
|
+
hostname: canonicalizeHostname(parsed.hostname),
|
|
784
844
|
protocol,
|
|
785
845
|
port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
|
|
786
846
|
};
|
|
@@ -808,7 +868,7 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
808
868
|
if (!requestOrigin) return false;
|
|
809
869
|
for (const entry of entries.values()) {
|
|
810
870
|
if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
|
|
811
|
-
if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) =>
|
|
871
|
+
if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => credentialHostMatches(requestOrigin.hostname, host))) return false;
|
|
812
872
|
}
|
|
813
873
|
return true;
|
|
814
874
|
},
|
|
@@ -833,15 +893,21 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
833
893
|
function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
|
|
834
894
|
const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
|
|
835
895
|
const isAllowed = (input) => {
|
|
836
|
-
|
|
837
|
-
|
|
896
|
+
let hostname;
|
|
897
|
+
try {
|
|
898
|
+
hostname = canonicalizeHostname(input.hostname);
|
|
899
|
+
} catch {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
const matching = origins.filter((origin) => origin.hosts.some((host) => credentialHostMatches(hostname, host)));
|
|
838
903
|
if (matching.length === 0) return true;
|
|
839
904
|
const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
|
|
840
|
-
|
|
905
|
+
options.onDecision?.({
|
|
841
906
|
hostname,
|
|
842
907
|
protocol: input.protocol,
|
|
843
908
|
port: input.port,
|
|
844
|
-
phase: input.phase
|
|
909
|
+
phase: input.phase,
|
|
910
|
+
allowed
|
|
845
911
|
});
|
|
846
912
|
return allowed;
|
|
847
913
|
};
|
|
@@ -970,6 +1036,16 @@ async function resumeVm(config) {
|
|
|
970
1036
|
const hostOriginHosts = hostOriginHostnames(hostOrigins);
|
|
971
1037
|
assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
|
|
972
1038
|
const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
|
|
1039
|
+
const requestedHostnamePolicy = Object.freeze({
|
|
1040
|
+
allowedHosts: Object.freeze([...allowedHosts].sort()),
|
|
1041
|
+
allowedInternalHosts: Object.freeze([...allowedInternalHosts].sort())
|
|
1042
|
+
});
|
|
1043
|
+
config.onDiagnostic?.({
|
|
1044
|
+
event: "vm.network.policy_bound",
|
|
1045
|
+
level: "info",
|
|
1046
|
+
message: "Passed the complete requested hostname policy to Gondolin",
|
|
1047
|
+
hostnamePolicy: requestedHostnamePolicy
|
|
1048
|
+
});
|
|
973
1049
|
const projectedEnv = config.guestProjection?.env ?? {};
|
|
974
1050
|
const brokeredSecrets = prepareBrokeredHttpSecrets({
|
|
975
1051
|
bindings: config.brokeredSecrets,
|
|
@@ -987,12 +1063,18 @@ async function resumeVm(config) {
|
|
|
987
1063
|
]
|
|
988
1064
|
});
|
|
989
1065
|
const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
|
|
990
|
-
const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], {
|
|
1066
|
+
const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDecision(decision) {
|
|
991
1067
|
config.onDiagnostic?.({
|
|
1068
|
+
event: "vm.network.origin_checked",
|
|
1069
|
+
level: "info",
|
|
1070
|
+
message: "Checked a canonical brokered credential origin",
|
|
1071
|
+
origin: decision
|
|
1072
|
+
});
|
|
1073
|
+
if (!decision.allowed) config.onDiagnostic?.({
|
|
992
1074
|
event: "vm.network.origin_denied",
|
|
993
1075
|
level: "warning",
|
|
994
|
-
message: "
|
|
995
|
-
origin
|
|
1076
|
+
message: "Denied a request outside a brokered credential origin",
|
|
1077
|
+
origin: decision
|
|
996
1078
|
});
|
|
997
1079
|
} });
|
|
998
1080
|
const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@themoltnet/sandbox-gondolin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Gondolin microVM sandbox lifecycle for MoltNet runtimes: checkpoint resume, egress policy, VFS shadowing, guest credential boundary, host-brokered secrets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moltnet",
|