@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 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
- onDenied?: (denial: RequestOrigin & {
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
- /** Present only for an exact-origin denial event. */
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 = [...new Set(descriptor.hosts.map(normalizeHostPattern))].sort();
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
- if (hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
690
- for (const host of hosts) if (!isValidHostPattern(host)) issues.push(`requirement "${id}" has invalid host pattern "${host}"`);
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(hosts),
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
- if (networkPattern === "*") return true;
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(normalizeHostPattern))];
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: normalizeHostPattern(parsed.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) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
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
- const hostname = normalizeHostPattern(input.hostname);
837
- const matching = origins.filter((origin) => origin.hosts.some((host) => hostMatchesPattern(hostname, host)));
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
- if (!allowed) options.onDenied?.({
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 ?? [], { onDenied(origin) {
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: "denied request outside a brokered credential origin",
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.4.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",