@markii/host 0.0.1 → 0.17.0-next.2

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.
Files changed (192) hide show
  1. package/README.md +18 -2
  2. package/dist/browser-worker.js +639 -0
  3. package/dist/browser.d.ts +57 -0
  4. package/dist/browser.js +167 -0
  5. package/dist/conformance/host/01-plain-open/expected.json +5 -0
  6. package/dist/conformance/host/01-plain-open/note.mk.md +8 -0
  7. package/dist/conformance/host/01-plain-open/scenario.json +6 -0
  8. package/dist/conformance/host/02-run-manual-grant-accepted/expected.json +16 -0
  9. package/dist/conformance/host/02-run-manual-grant-accepted/note.mk.md +5 -0
  10. package/dist/conformance/host/02-run-manual-grant-accepted/scenario.json +14 -0
  11. package/dist/conformance/host/03-run-manual-grant-refused/expected.json +18 -0
  12. package/dist/conformance/host/03-run-manual-grant-refused/note.mk.md +5 -0
  13. package/dist/conformance/host/03-run-manual-grant-refused/scenario.json +14 -0
  14. package/dist/conformance/host/04-run-auto-write-denied/expected.json +16 -0
  15. package/dist/conformance/host/04-run-auto-write-denied/note.mk.md +10 -0
  16. package/dist/conformance/host/04-run-auto-write-denied/scenario.json +13 -0
  17. package/dist/conformance/host/05-export-html-pack-directive/expected.json +14 -0
  18. package/dist/conformance/host/05-export-html-pack-directive/note.mk.md +8 -0
  19. package/dist/conformance/host/05-export-html-pack-directive/scenario.json +6 -0
  20. package/dist/conformance/host/06-install-pack-then-render/expected.json +18 -0
  21. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/pack.json +13 -0
  22. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/webview.js +1 -0
  23. package/dist/conformance/host/06-install-pack-then-render/note.mk.md +5 -0
  24. package/dist/conformance/host/06-install-pack-then-render/scenario.json +15 -0
  25. package/dist/conformance/host/07-complete-in-directive/expected.json +14 -0
  26. package/dist/conformance/host/07-complete-in-directive/note.mk.md +3 -0
  27. package/dist/conformance/host/07-complete-in-directive/scenario.json +6 -0
  28. package/dist/conformance/host/08-hover-standard-component/expected.json +5 -0
  29. package/dist/conformance/host/08-hover-standard-component/note.mk.md +5 -0
  30. package/dist/conformance/host/08-hover-standard-component/scenario.json +6 -0
  31. package/dist/conformance/host/09-insert-skeleton/expected.json +5 -0
  32. package/dist/conformance/host/09-insert-skeleton/note.mk.md +4 -0
  33. package/dist/conformance/host/09-insert-skeleton/scenario.json +6 -0
  34. package/dist/conformance/host/10-scripts-disabled/expected.json +11 -0
  35. package/dist/conformance/host/10-scripts-disabled/note.mk.md +5 -0
  36. package/dist/conformance/host/10-scripts-disabled/scenario.json +9 -0
  37. package/dist/conformance/host/11-complete-closing-fence/expected.json +5 -0
  38. package/dist/conformance/host/11-complete-closing-fence/note.mk.md +5 -0
  39. package/dist/conformance/host/11-complete-closing-fence/scenario.json +12 -0
  40. package/dist/conformance/host/12-complete-nested-container/expected.json +5 -0
  41. package/dist/conformance/host/12-complete-nested-container/note.mk.md +5 -0
  42. package/dist/conformance/host/12-complete-nested-container/scenario.json +12 -0
  43. package/dist/conformance/host/13-install-pack-declined/expected.json +12 -0
  44. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/pack.json +13 -0
  45. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/webview.js +1 -0
  46. package/dist/conformance/host/13-install-pack-declined/note.mk.md +5 -0
  47. package/dist/conformance/host/13-install-pack-declined/scenario.json +10 -0
  48. package/dist/conformance/host/14-remove-pack/expected.json +14 -0
  49. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/pack.json +13 -0
  50. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/webview.js +1 -0
  51. package/dist/conformance/host/14-remove-pack/note.mk.md +5 -0
  52. package/dist/conformance/host/14-remove-pack/scenario.json +11 -0
  53. package/dist/conformance/host/15-load-packs-engine-views/expected.json +19 -0
  54. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/pack.json +9 -0
  55. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/webview.js +1 -0
  56. package/dist/conformance/host/15-load-packs-engine-views/note.mk.md +9 -0
  57. package/dist/conformance/host/15-load-packs-engine-views/scenario.json +10 -0
  58. package/dist/conformance/host/16-pack-trust/expected.json +37 -0
  59. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/pack.json +12 -0
  60. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/webview.js +1 -0
  61. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/pack.json +12 -0
  62. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/webview.js +1 -0
  63. package/dist/conformance/host/16-pack-trust/note.mk.md +5 -0
  64. package/dist/conformance/host/16-pack-trust/scenario.json +26 -0
  65. package/dist/conformance/host/README.md +221 -0
  66. package/dist/conformance.d.ts +2 -0
  67. package/dist/conformance.js +5 -0
  68. package/dist/diagnostics/render-diagnostics.d.ts +41 -0
  69. package/dist/diagnostics/render-diagnostics.js +46 -0
  70. package/dist/export/cascade.d.ts +98 -0
  71. package/dist/export/cascade.js +159 -0
  72. package/dist/export/export-zip.d.ts +19 -0
  73. package/dist/export/export-zip.js +38 -0
  74. package/dist/export/image-embed.d.ts +126 -0
  75. package/dist/export/image-embed.js +274 -0
  76. package/dist/export/note-export.d.ts +332 -0
  77. package/dist/export/note-export.js +366 -0
  78. package/dist/export/note-links.d.ts +83 -0
  79. package/dist/export/note-links.js +241 -0
  80. package/dist/glue.wasm +0 -0
  81. package/dist/host/adapter.d.ts +223 -0
  82. package/dist/host/adapter.js +9 -0
  83. package/dist/host/cascade-export-behavior.d.ts +120 -0
  84. package/dist/host/cascade-export-behavior.js +151 -0
  85. package/dist/host/conformance-runner.d.ts +156 -0
  86. package/dist/host/conformance-runner.js +634 -0
  87. package/dist/host/create-host.d.ts +253 -0
  88. package/dist/host/create-host.js +329 -0
  89. package/dist/host/editor-behavior.d.ts +157 -0
  90. package/dist/host/editor-behavior.js +227 -0
  91. package/dist/host/export/md-plain.d.ts +15 -0
  92. package/dist/host/export/md-plain.js +125 -0
  93. package/dist/host/export-behavior.d.ts +113 -0
  94. package/dist/host/export-behavior.js +257 -0
  95. package/dist/host/labels.d.ts +24 -0
  96. package/dist/host/labels.js +32 -0
  97. package/dist/host/pack-archive.d.ts +30 -0
  98. package/dist/host/pack-archive.js +68 -0
  99. package/dist/host/pack-install.d.ts +88 -0
  100. package/dist/host/pack-install.js +189 -0
  101. package/dist/host/pack-load.d.ts +72 -0
  102. package/dist/host/pack-load.js +57 -0
  103. package/dist/host/pack-remove.d.ts +21 -0
  104. package/dist/host/pack-remove.js +65 -0
  105. package/dist/host/pack-trust-behavior.d.ts +79 -0
  106. package/dist/host/pack-trust-behavior.js +262 -0
  107. package/dist/host/refresh-interval.d.ts +39 -0
  108. package/dist/host/refresh-interval.js +61 -0
  109. package/dist/host/run-behavior.d.ts +89 -0
  110. package/dist/host/run-behavior.js +168 -0
  111. package/dist/host/run-diagnostics.d.ts +26 -0
  112. package/dist/host/run-diagnostics.js +43 -0
  113. package/dist/host/script-execution.d.ts +72 -0
  114. package/dist/host/script-execution.js +68 -0
  115. package/dist/host/terminal-packs.d.ts +39 -0
  116. package/dist/host/terminal-packs.js +51 -0
  117. package/dist/html-engine.d.ts +2 -0
  118. package/dist/html-engine.js +29 -0
  119. package/dist/index.d.ts +95 -0
  120. package/dist/index.js +70 -0
  121. package/dist/insert/component-catalog.d.ts +23 -0
  122. package/dist/insert/component-catalog.js +96 -0
  123. package/dist/node-fs.d.ts +14 -0
  124. package/dist/node-fs.js +70 -0
  125. package/dist/node.d.ts +5 -0
  126. package/dist/node.js +13 -0
  127. package/dist/package.json +3 -0
  128. package/dist/packs/discover.d.ts +119 -0
  129. package/dist/packs/discover.js +266 -0
  130. package/dist/packs/pack-diagnostics.d.ts +81 -0
  131. package/dist/packs/pack-diagnostics.js +105 -0
  132. package/dist/packs/pack-paths.d.ts +53 -0
  133. package/dist/packs/pack-paths.js +106 -0
  134. package/dist/packs/pack-render-registry.d.ts +83 -0
  135. package/dist/packs/pack-render-registry.js +182 -0
  136. package/dist/packs/pack-scripts.d.ts +20 -0
  137. package/dist/packs/pack-scripts.js +96 -0
  138. package/dist/packs/pack-trust.d.ts +57 -0
  139. package/dist/packs/pack-trust.js +111 -0
  140. package/dist/packs/prebuilt.d.ts +49 -0
  141. package/dist/packs/prebuilt.js +97 -0
  142. package/dist/path-util.d.ts +24 -0
  143. package/dist/path-util.js +82 -0
  144. package/dist/run/browser-isolate.d.ts +45 -0
  145. package/dist/run/browser-isolate.js +81 -0
  146. package/dist/run/bundle-document.d.ts +29 -0
  147. package/dist/run/bundle-document.js +40 -0
  148. package/dist/run/bundle-run.d.ts +188 -0
  149. package/dist/run/bundle-run.js +232 -0
  150. package/dist/run/grant-flow.d.ts +232 -0
  151. package/dist/run/grant-flow.js +390 -0
  152. package/dist/run/ip-address.d.ts +41 -0
  153. package/dist/run/ip-address.js +255 -0
  154. package/dist/run/isolate.d.ts +57 -0
  155. package/dist/run/isolate.js +22 -0
  156. package/dist/run/lua-resolver.d.ts +21 -0
  157. package/dist/run/lua-resolver.js +63 -0
  158. package/dist/run/net-bridge-worker.d.ts +26 -0
  159. package/dist/run/net-bridge-worker.js +66 -0
  160. package/dist/run/net-bridge.d.ts +56 -0
  161. package/dist/run/net-bridge.js +59 -0
  162. package/dist/run/net-pinning.d.ts +82 -0
  163. package/dist/run/net-pinning.js +137 -0
  164. package/dist/run/net-provider.d.ts +70 -0
  165. package/dist/run/net-provider.js +289 -0
  166. package/dist/run/node-isolate.d.ts +30 -0
  167. package/dist/run/node-isolate.js +117 -0
  168. package/dist/run/run-flow.d.ts +187 -0
  169. package/dist/run/run-flow.js +323 -0
  170. package/dist/run/run-host.d.ts +107 -0
  171. package/dist/run/run-host.js +241 -0
  172. package/dist/run/run-job.d.ts +130 -0
  173. package/dist/run/run-job.js +279 -0
  174. package/dist/run/run-progress.d.ts +55 -0
  175. package/dist/run/run-progress.js +42 -0
  176. package/dist/run/run-trace.d.ts +44 -0
  177. package/dist/run/run-trace.js +36 -0
  178. package/dist/run/script-requirements.d.ts +98 -0
  179. package/dist/run/script-requirements.js +212 -0
  180. package/dist/run/snapshot-storage.d.ts +25 -0
  181. package/dist/run/snapshot-storage.js +31 -0
  182. package/dist/run/stale-values.d.ts +25 -0
  183. package/dist/run/stale-values.js +32 -0
  184. package/dist/run/worker-entry-browser.d.ts +42 -0
  185. package/dist/run/worker-entry-browser.js +77 -0
  186. package/dist/run/worker-entry.d.ts +1 -0
  187. package/dist/run/worker-entry.js +96 -0
  188. package/dist/values-failure.d.ts +17 -0
  189. package/dist/values-failure.js +1 -0
  190. package/dist/values-merge.d.ts +39 -0
  191. package/dist/values-merge.js +33 -0
  192. package/package.json +77 -4
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Resolve-then-pin for the run path's network jail (GitHub issue #10).
3
+ *
4
+ * A network grant is a hostname string. Between granting the name and
5
+ * reaching it, two things can go wrong that a hostname check cannot see:
6
+ *
7
+ * - the name can resolve somewhere the user never intended (a public name
8
+ * answering with a private address, which is SSRF against their own
9
+ * machine or LAN, including the cloud metadata address);
10
+ * - the record can change between the check and the connection (DNS
11
+ * rebinding), so even a checked name can be connected to somewhere else.
12
+ *
13
+ * This module closes both by resolving a host ONCE, vetting every address the
14
+ * resolver returned, and handing back the single address the request must
15
+ * then be pinned to. `docs/security.md` documented these as accepted limits
16
+ * of a hostname allowlist; this is the code that closes them.
17
+ *
18
+ * Pure apart from the injected `lookup`, so every branch is testable without
19
+ * a resolver or a socket.
20
+ */
21
+ import { type AddressScope } from './ip-address.js';
22
+ /** One address a resolver returned. Matches `dns.promises.lookup(host, { all: true })`'s shape. */
23
+ export interface ResolvedAddress {
24
+ address: string;
25
+ family: number;
26
+ }
27
+ /** Resolves a hostname to every address it has. Injected so tests never touch a real resolver. */
28
+ export type HostLookup = (hostname: string) => Promise<ResolvedAddress[]>;
29
+ /** The address a request is pinned to, and what kind of place it is. */
30
+ export interface PinnedAddress {
31
+ address: string;
32
+ family: 4 | 6;
33
+ scope: AddressScope;
34
+ /** True when the grant named an address (or `localhost`) directly, so no name-to-address surprise was possible. */
35
+ literal: boolean;
36
+ }
37
+ export type PinResult = {
38
+ ok: true;
39
+ pinned: PinnedAddress;
40
+ } | {
41
+ ok: false;
42
+ reason: string;
43
+ };
44
+ export interface PinPolicy {
45
+ /**
46
+ * The deployment opt-in from issue #10: allow a NAMED host to resolve into
47
+ * a private, loopback, or link-local range. Off by default, and off is the
48
+ * posture that closes the SSRF case. A deployment whose internal DNS
49
+ * legitimately points names at RFC 1918 space (a corporate `git.internal`)
50
+ * turns it on knowingly.
51
+ */
52
+ allowRestrictedAddresses: boolean;
53
+ }
54
+ /**
55
+ * Vets `hostname` and returns the one address the caller must connect to.
56
+ *
57
+ * The rule, in one sentence: a name may only be reached at a public address,
58
+ * unless the grant itself named a literal address or `localhost` (in which
59
+ * case the user chose that address knowingly, and the prompt says so), or the
60
+ * deployment has opted in.
61
+ *
62
+ * A host whose resolver returns a mix of public and restricted addresses is
63
+ * refused OUTRIGHT rather than filtered down to the public ones. Picking the
64
+ * public address out of a mixed answer is precisely what a rebinding attacker
65
+ * wants: it keeps their name reachable while they wait for the pin to expire.
66
+ */
67
+ export declare function pinHostAddress(hostname: string, policy: PinPolicy, lookup: HostLookup): Promise<PinResult>;
68
+ /**
69
+ * The `lookup` a pinned request installs on its own connection. Node calls it
70
+ * instead of the real resolver, so the socket can only ever go to
71
+ * `pinned.address` — the resolve-then-connect gap is closed by construction
72
+ * rather than by re-checking after the fact.
73
+ *
74
+ * The callback has two shapes and BOTH must be handled: Node's own
75
+ * `net.connect` calls this with `{ all: true }` and expects an array, while
76
+ * the single-address form is what a caller passing `{ all: false }` gets.
77
+ * Answering only the single form throws `ERR_INVALID_IP_ADDRESS` inside
78
+ * Node's connect path (verified against Node 22).
79
+ */
80
+ export declare function pinnedLookup(pinned: PinnedAddress): (_hostname: string, options: {
81
+ all?: boolean;
82
+ }, callback: (err: NodeJS.ErrnoException | null, address: string | ResolvedAddress[], family?: number) => void) => void;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Resolve-then-pin for the run path's network jail (GitHub issue #10).
3
+ *
4
+ * A network grant is a hostname string. Between granting the name and
5
+ * reaching it, two things can go wrong that a hostname check cannot see:
6
+ *
7
+ * - the name can resolve somewhere the user never intended (a public name
8
+ * answering with a private address, which is SSRF against their own
9
+ * machine or LAN, including the cloud metadata address);
10
+ * - the record can change between the check and the connection (DNS
11
+ * rebinding), so even a checked name can be connected to somewhere else.
12
+ *
13
+ * This module closes both by resolving a host ONCE, vetting every address the
14
+ * resolver returned, and handing back the single address the request must
15
+ * then be pinned to. `docs/security.md` documented these as accepted limits
16
+ * of a hostname allowlist; this is the code that closes them.
17
+ *
18
+ * Pure apart from the injected `lookup`, so every branch is testable without
19
+ * a resolver or a socket.
20
+ */
21
+ import { classifyIpAddress, describeScope, isRestrictedScope, } from './ip-address.js';
22
+ /**
23
+ * `localhost` is special-cased alongside IP literals, per RFC 6761: it and
24
+ * anything under `.localhost` are defined to mean the loopback interface, so
25
+ * granting the name IS granting loopback. There is no surprise to protect the
26
+ * user from and no rebinding to prevent, since the name is not resolved on
27
+ * the network at all.
28
+ */
29
+ function isLoopbackName(hostname) {
30
+ const lower = hostname.toLowerCase();
31
+ return lower === 'localhost' || lower.endsWith('.localhost');
32
+ }
33
+ /**
34
+ * Vets `hostname` and returns the one address the caller must connect to.
35
+ *
36
+ * The rule, in one sentence: a name may only be reached at a public address,
37
+ * unless the grant itself named a literal address or `localhost` (in which
38
+ * case the user chose that address knowingly, and the prompt says so), or the
39
+ * deployment has opted in.
40
+ *
41
+ * A host whose resolver returns a mix of public and restricted addresses is
42
+ * refused OUTRIGHT rather than filtered down to the public ones. Picking the
43
+ * public address out of a mixed answer is precisely what a rebinding attacker
44
+ * wants: it keeps their name reachable while they wait for the pin to expire.
45
+ */
46
+ export async function pinHostAddress(hostname, policy, lookup) {
47
+ const literalScope = classifyIpAddress(hostname);
48
+ if (literalScope !== undefined) {
49
+ const bare = hostname.startsWith('[') && hostname.endsWith(']')
50
+ ? hostname.slice(1, -1)
51
+ : hostname;
52
+ // The grant named this exact address: there is nothing to resolve, so
53
+ // there is nothing to pin against and no range to second-guess.
54
+ return {
55
+ ok: true,
56
+ pinned: {
57
+ address: bare,
58
+ family: bare.includes(':') ? 6 : 4,
59
+ scope: literalScope,
60
+ literal: true,
61
+ },
62
+ };
63
+ }
64
+ if (isLoopbackName(hostname)) {
65
+ return {
66
+ ok: true,
67
+ pinned: {
68
+ address: '127.0.0.1',
69
+ family: 4,
70
+ scope: 'loopback',
71
+ literal: true,
72
+ },
73
+ };
74
+ }
75
+ let addresses;
76
+ try {
77
+ addresses = await lookup(hostname);
78
+ }
79
+ catch {
80
+ // Deliberately not the resolver's own message: it varies by platform and
81
+ // says nothing a note's author can act on.
82
+ return { ok: false, reason: `host "${hostname}" could not be resolved` };
83
+ }
84
+ if (addresses.length === 0) {
85
+ return { ok: false, reason: `host "${hostname}" resolved to no address` };
86
+ }
87
+ for (const candidate of addresses) {
88
+ const scope = classifyIpAddress(candidate.address);
89
+ if (scope === undefined) {
90
+ return {
91
+ ok: false,
92
+ reason: `host "${hostname}" resolved to an unusable address`,
93
+ };
94
+ }
95
+ if (isRestrictedScope(scope) && !policy.allowRestrictedAddresses) {
96
+ return {
97
+ ok: false,
98
+ reason: `host "${hostname}" resolves to ${describeScope(scope)}`,
99
+ };
100
+ }
101
+ }
102
+ const chosen = addresses[0];
103
+ if (chosen === undefined) {
104
+ return { ok: false, reason: `host "${hostname}" resolved to no address` };
105
+ }
106
+ const scope = classifyIpAddress(chosen.address) ?? 'reserved';
107
+ return {
108
+ ok: true,
109
+ pinned: {
110
+ address: chosen.address,
111
+ family: chosen.family === 6 ? 6 : 4,
112
+ scope,
113
+ literal: false,
114
+ },
115
+ };
116
+ }
117
+ /**
118
+ * The `lookup` a pinned request installs on its own connection. Node calls it
119
+ * instead of the real resolver, so the socket can only ever go to
120
+ * `pinned.address` — the resolve-then-connect gap is closed by construction
121
+ * rather than by re-checking after the fact.
122
+ *
123
+ * The callback has two shapes and BOTH must be handled: Node's own
124
+ * `net.connect` calls this with `{ all: true }` and expects an array, while
125
+ * the single-address form is what a caller passing `{ all: false }` gets.
126
+ * Answering only the single form throws `ERR_INVALID_IP_ADDRESS` inside
127
+ * Node's connect path (verified against Node 22).
128
+ */
129
+ export function pinnedLookup(pinned) {
130
+ return (_hostname, options, callback) => {
131
+ if (options.all === true) {
132
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
133
+ return;
134
+ }
135
+ callback(null, pinned.address, pinned.family);
136
+ };
137
+ }
@@ -0,0 +1,70 @@
1
+ import type { NetProvider } from '@markii/lua';
2
+ import { type HostLookup, type PinPolicy } from './net-pinning.js';
3
+ /**
4
+ * The worker's `NetProvider` (`@markii/lua`): built on `node:http`/
5
+ * `node:https` rather than global `fetch` (GitHub issue #10) — `fetch` has
6
+ * no way to pin where a request's socket connects short of installing an
7
+ * undici dispatcher, and adding a dependency for that is out of scope
8
+ * (AGENTS.md's Stack list). `node:http`/`node:https`'s own `lookup` request
9
+ * option does the same job without one — see `issueRequest`'s doc comment
10
+ * for what it does and does not affect.
11
+ *
12
+ * One allowlist governs `get`/`post`/`patch` alike (docs/security.md: "the
13
+ * per-host allowlist is the real boundary", not a GET/POST distinction).
14
+ * This is DEFENSE IN DEPTH, not the primary gate — the primary gate is
15
+ * `netGrants` passed to `createLuaExecutor` below, which `@markii/lua`'s
16
+ * `buildCapabilities` already enforces before this provider is ever called
17
+ * (a disallowed host never reaches here at all; it comes back as the
18
+ * standard `'capability-denied'` failure kind, recorded through
19
+ * `@markii/lua`'s non-spoofable denial-recording path — see
20
+ * `capabilities.ts`).
21
+ *
22
+ * Per hop of a redirect chain, in this order:
23
+ * 1. the scheme must be `http:` or `https:` — anything else is refused;
24
+ * 2. a URL carrying credentials (`https://user:pass@host/...`) is refused
25
+ * outright (N-4) — `fetch` used to give this for free by refusing to
26
+ * construct a credentialed `Request`; this loop checks it explicitly
27
+ * instead, on every hop, not only the first;
28
+ * 3. the hop's hostname is checked against `allowlist` — unchanged from
29
+ * before this port. An allowed host redirecting the request elsewhere
30
+ * is exactly the SSRF shape a host-string allowlist is meant to close,
31
+ * and `buildCapabilities` only ever sees the ORIGINAL request URL,
32
+ * never where a 3xx response actually sent the request. A hop landing
33
+ * on a non-allowed host is refused WITHOUT that hop's request ever
34
+ * being made (B-1);
35
+ * 4. `pinHostAddress` (`./net-pinning.ts`) resolves that hostname ONCE and
36
+ * vets every address it got back against `policy` — the new
37
+ * DNS-rebinding/private-range close from issue #10: a hostname
38
+ * allowlist alone cannot see where a name actually resolves, or that
39
+ * the record can change between the check and the connect;
40
+ * 5. the request is issued pinned to the vetted address (`issueRequest`),
41
+ * so the socket can only ever reach where step 4 vetted — the
42
+ * resolve-then-connect gap is closed by construction, not by
43
+ * re-checking after the fact.
44
+ *
45
+ * The response body is read bounded to `maxFetchBytes`, never buffered
46
+ * whole first — see `issueRequest` (B-2).
47
+ */
48
+ export declare function createNetProvider(allowlist: readonly string[], maxFetchBytes: number, policy: PinPolicy,
49
+ /**
50
+ * How a POLICY refusal is built. The worker passes `@markii/lua`'s
51
+ * `netProviderDenial`, whose `Symbol` brand is what makes a refusal
52
+ * classify as `'capability-denied'` instead of a generic script error.
53
+ *
54
+ * It is injected rather than imported so this module's network half does
55
+ * not drag the Lua runtime in behind it. The Obsidian plugin runs this
56
+ * provider in its RENDERER, on behalf of a Web Worker isolate that has no
57
+ * `node:dns` to pin with, and that renderer bundle must not contain a
58
+ * WebAssembly Lua engine it never runs. The brand is not lost there: the
59
+ * host marks every refusal on the wire (`./net-bridge.ts`'s `denied`
60
+ * flag) and the worker rebuilds a properly branded error on arrival.
61
+ */
62
+ denial: (message: string) => Error, lookup?: HostLookup): NetProvider;
63
+ /**
64
+ * An in-memory `CacheProvider` (`@markii/lua`) seeded from `snapshot` at
65
+ * construction and readable back out afterward via `.snapshot()` — the
66
+ * "snapshot-in/snapshot-out" design from the locked run-arc comment. No
67
+ * disk/IndexedDB access here: persisting the returned snapshot across runs
68
+ * is `run-host.ts`'s caller's job (extension storage, in the real
69
+ * extension).
70
+ */
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The pinned network provider: the code that actually performs a `net.*`
3
+ * request, follows redirects, re-checks every hop against the allowlist,
4
+ * and connects to an address it vetted itself (GitHub issue #10,
5
+ * docs/security.md).
6
+ *
7
+ * It lives in its own module, with NO `@markii/lua` runtime import, because
8
+ * it now runs in two very different places. In the VS Code extension it
9
+ * runs inside the worker thread, as it always has. In the Obsidian plugin
10
+ * it runs in the RENDERER, on behalf of a Web Worker isolate that has no
11
+ * `node:dns` to pin with and therefore cannot do this for itself (see
12
+ * `./browser-isolate.ts`). That renderer bundle must not contain a
13
+ * WebAssembly Lua engine it never runs, so the one value this file used to
14
+ * import from `@markii/lua` — the brand that marks a policy refusal — is
15
+ * injected as `denial` instead. Types are `import type` and erase.
16
+ *
17
+ * The security properties are unchanged by the move, and one improves: an
18
+ * isolate that has no network stack cannot bypass the allowlist enforced
19
+ * here, because it has nothing to bypass it with.
20
+ */
21
+ import * as http from 'node:http';
22
+ import * as https from 'node:https';
23
+ import * as dns from 'node:dns';
24
+ import { pinHostAddress, pinnedLookup, } from './net-pinning.js';
25
+ function describeThrown(err) {
26
+ return err instanceof Error ? err.message : String(err);
27
+ }
28
+ /**
29
+ * A same-hop, same-host redirect chain is capped at this many hops (B-1):
30
+ * an allowed host is free to redirect a handful of times (a login/CDN
31
+ * bounce is common), but an unbounded chain is itself a resource-abuse
32
+ * shape worth refusing outright rather than following forever.
33
+ */
34
+ const MAX_REDIRECTS = 5;
35
+ /**
36
+ * The resolver `createNetProvider` pins against when the host does not
37
+ * inject one (GitHub issue #10): every address a real DNS lookup returns
38
+ * for a hostname, in the shape `pinHostAddress` (`./net-pinning.ts`)
39
+ * expects. `all: true` is what makes this the FULL set of addresses a name
40
+ * resolves to, rather than just the one Node's default resolution would
41
+ * pick — vetting only the address Node happened to choose is exactly the
42
+ * incomplete check a rebinding attacker relies on. `verbatim: true` asks
43
+ * Node not to reorder that set for connection-preference reasons, which
44
+ * have nothing to do with vetting it.
45
+ */
46
+ const defaultHostLookup = async (hostname) => {
47
+ const results = await dns.promises.lookup(hostname, {
48
+ all: true,
49
+ verbatim: true,
50
+ });
51
+ return results.map((entry) => ({
52
+ address: entry.address,
53
+ family: entry.family,
54
+ }));
55
+ };
56
+ function bareHostname(hostname) {
57
+ return hostname.startsWith('[') && hostname.endsWith(']')
58
+ ? hostname.slice(1, -1)
59
+ : hostname;
60
+ }
61
+ /**
62
+ * Issues exactly one HTTP(S) request, pinned to `pinned.address` via
63
+ * `pinnedLookup` (`./net-pinning.ts`, GitHub issue #10) — Node's `lookup`
64
+ * request option is what makes this possible without `fetch` (which has no
65
+ * such hook short of an undici dispatcher, and adding a dependency for that
66
+ * is out of scope; see `createNetProvider`'s doc comment). The socket can
67
+ * only ever connect to `pinned.address`, while `url.hostname` still drives
68
+ * the `Host` header and, for `https:`, the TLS certificate check and SNI —
69
+ * `rejectUnauthorized` is never touched here, so a certificate that does
70
+ * not cover the real hostname still fails the handshake (verified
71
+ * empirically against Node 22). `agent: false` so no pooled connection is
72
+ * ever reused across hosts or hops.
73
+ *
74
+ * The response body is read bounded to `maxFetchBytes` (B-2): a
75
+ * `content-length` header over the cap is rejected WITHOUT reading
76
+ * anything, and otherwise the read is aborted — the response socket is
77
+ * destroyed — the moment the running byte total exceeds the cap. The whole
78
+ * body is never buffered before that check runs; `chunks` only ever holds
79
+ * bytes already known to be within the cap.
80
+ */
81
+ function issueRequest(url, method, body, pinned, maxFetchBytes,
82
+ /** See `createNetProvider`'s own `denial` parameter — injected for the same reason. */
83
+ denial) {
84
+ return new Promise((resolve, reject) => {
85
+ const mod = url.protocol === 'https:' ? https : http;
86
+ const bodyBuffer = body !== undefined ? Buffer.from(body, 'utf8') : undefined;
87
+ // The headers `fetch` used to add on its own, restored explicitly now
88
+ // that this path builds the request itself. They are not cosmetic: with
89
+ // no `user-agent`, api.github.com answers 403 ("Please make sure your
90
+ // request has a User-Agent"), which would break every note fetching the
91
+ // GitHub API while every local-server test still passed. The values are
92
+ // exactly what Node's `fetch` sent for a string body, so this port
93
+ // changes nothing that leaves the machine.
94
+ const headers = {
95
+ 'user-agent': 'node',
96
+ accept: '*/*',
97
+ };
98
+ if (bodyBuffer) {
99
+ headers['content-length'] = String(bodyBuffer.byteLength);
100
+ headers['content-type'] = 'text/plain;charset=UTF-8';
101
+ }
102
+ // Exactly-once settlement: several of the events below (`res`'s
103
+ // 'error' after a deliberate `destroy()`, `req`'s 'error' after the
104
+ // response already resolved) can fire in close succession once
105
+ // something has been torn down early. Every later one is a no-op.
106
+ let settled = false;
107
+ const settleResolve = (value) => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ resolve(value);
112
+ };
113
+ const settleReject = (error) => {
114
+ if (settled)
115
+ return;
116
+ settled = true;
117
+ reject(error);
118
+ };
119
+ const req = mod.request({
120
+ protocol: url.protocol,
121
+ hostname: bareHostname(url.hostname),
122
+ port: url.port === '' ? undefined : Number(url.port),
123
+ path: `${url.pathname}${url.search}`,
124
+ method,
125
+ headers,
126
+ agent: false,
127
+ // Pins the socket to the vetted address (issue #10) without
128
+ // touching the `Host` header or the TLS certificate check — both
129
+ // are still driven by `hostname` above.
130
+ lookup: pinnedLookup(pinned),
131
+ }, (res) => {
132
+ const declaredLength = res.headers['content-length'];
133
+ if (declaredLength !== undefined) {
134
+ const declared = Number(declaredLength);
135
+ if (Number.isFinite(declared) && declared > maxFetchBytes) {
136
+ res.destroy();
137
+ settleReject(denial(`response declares ${declared} bytes, exceeding the ${maxFetchBytes}-byte cap`));
138
+ return;
139
+ }
140
+ }
141
+ let total = 0;
142
+ const chunks = [];
143
+ res.on('data', (chunk) => {
144
+ if (settled)
145
+ return;
146
+ total += chunk.byteLength;
147
+ if (total > maxFetchBytes) {
148
+ res.destroy();
149
+ settleReject(denial(`response exceeds the ${maxFetchBytes}-byte cap`));
150
+ return;
151
+ }
152
+ chunks.push(chunk);
153
+ });
154
+ res.on('end', () => {
155
+ settleResolve({
156
+ status: res.statusCode ?? 0,
157
+ location: res.headers.location,
158
+ body: Buffer.concat(chunks).toString('utf8'),
159
+ });
160
+ });
161
+ res.on('error', (err) => settleReject(err));
162
+ });
163
+ req.on('error', (err) => {
164
+ settleReject(denial(`request failed: ${describeThrown(err)}`));
165
+ });
166
+ if (bodyBuffer)
167
+ req.write(bodyBuffer);
168
+ req.end();
169
+ });
170
+ }
171
+ /**
172
+ * The worker's `NetProvider` (`@markii/lua`): built on `node:http`/
173
+ * `node:https` rather than global `fetch` (GitHub issue #10) — `fetch` has
174
+ * no way to pin where a request's socket connects short of installing an
175
+ * undici dispatcher, and adding a dependency for that is out of scope
176
+ * (AGENTS.md's Stack list). `node:http`/`node:https`'s own `lookup` request
177
+ * option does the same job without one — see `issueRequest`'s doc comment
178
+ * for what it does and does not affect.
179
+ *
180
+ * One allowlist governs `get`/`post`/`patch` alike (docs/security.md: "the
181
+ * per-host allowlist is the real boundary", not a GET/POST distinction).
182
+ * This is DEFENSE IN DEPTH, not the primary gate — the primary gate is
183
+ * `netGrants` passed to `createLuaExecutor` below, which `@markii/lua`'s
184
+ * `buildCapabilities` already enforces before this provider is ever called
185
+ * (a disallowed host never reaches here at all; it comes back as the
186
+ * standard `'capability-denied'` failure kind, recorded through
187
+ * `@markii/lua`'s non-spoofable denial-recording path — see
188
+ * `capabilities.ts`).
189
+ *
190
+ * Per hop of a redirect chain, in this order:
191
+ * 1. the scheme must be `http:` or `https:` — anything else is refused;
192
+ * 2. a URL carrying credentials (`https://user:pass@host/...`) is refused
193
+ * outright (N-4) — `fetch` used to give this for free by refusing to
194
+ * construct a credentialed `Request`; this loop checks it explicitly
195
+ * instead, on every hop, not only the first;
196
+ * 3. the hop's hostname is checked against `allowlist` — unchanged from
197
+ * before this port. An allowed host redirecting the request elsewhere
198
+ * is exactly the SSRF shape a host-string allowlist is meant to close,
199
+ * and `buildCapabilities` only ever sees the ORIGINAL request URL,
200
+ * never where a 3xx response actually sent the request. A hop landing
201
+ * on a non-allowed host is refused WITHOUT that hop's request ever
202
+ * being made (B-1);
203
+ * 4. `pinHostAddress` (`./net-pinning.ts`) resolves that hostname ONCE and
204
+ * vets every address it got back against `policy` — the new
205
+ * DNS-rebinding/private-range close from issue #10: a hostname
206
+ * allowlist alone cannot see where a name actually resolves, or that
207
+ * the record can change between the check and the connect;
208
+ * 5. the request is issued pinned to the vetted address (`issueRequest`),
209
+ * so the socket can only ever reach where step 4 vetted — the
210
+ * resolve-then-connect gap is closed by construction, not by
211
+ * re-checking after the fact.
212
+ *
213
+ * The response body is read bounded to `maxFetchBytes`, never buffered
214
+ * whole first — see `issueRequest` (B-2).
215
+ */
216
+ export function createNetProvider(allowlist, maxFetchBytes, policy,
217
+ /**
218
+ * How a POLICY refusal is built. The worker passes `@markii/lua`'s
219
+ * `netProviderDenial`, whose `Symbol` brand is what makes a refusal
220
+ * classify as `'capability-denied'` instead of a generic script error.
221
+ *
222
+ * It is injected rather than imported so this module's network half does
223
+ * not drag the Lua runtime in behind it. The Obsidian plugin runs this
224
+ * provider in its RENDERER, on behalf of a Web Worker isolate that has no
225
+ * `node:dns` to pin with, and that renderer bundle must not contain a
226
+ * WebAssembly Lua engine it never runs. The brand is not lost there: the
227
+ * host marks every refusal on the wire (`./net-bridge.ts`'s `denied`
228
+ * flag) and the worker rebuilds a properly branded error on arrival.
229
+ */
230
+ denial, lookup = defaultHostLookup) {
231
+ const allowed = new Set(allowlist.map((host) => host.toLowerCase()));
232
+ async function fetchAllowed(startUrl, method, body) {
233
+ let currentUrl = startUrl;
234
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
235
+ let parsed;
236
+ try {
237
+ parsed = new URL(currentUrl);
238
+ }
239
+ catch {
240
+ throw denial(`"${currentUrl}" is not a valid URL`);
241
+ }
242
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
243
+ throw denial(`scheme "${parsed.protocol}" is not allowed`);
244
+ }
245
+ // N-4: a URL carrying credentials is refused outright, on every hop.
246
+ if (parsed.username !== '' || parsed.password !== '') {
247
+ throw denial(`URL "${currentUrl}" embeds credentials, which is not allowed`);
248
+ }
249
+ const host = parsed.hostname.toLowerCase();
250
+ if (!allowed.has(host)) {
251
+ throw denial(`host "${host}" is not on the run's allowlist`);
252
+ }
253
+ // Issue #10: resolve-then-pin, closing the DNS-rebinding / private-
254
+ // range SSRF gap a hostname allowlist alone cannot see.
255
+ const pinResult = await pinHostAddress(host, policy, lookup);
256
+ if (!pinResult.ok) {
257
+ throw denial(pinResult.reason);
258
+ }
259
+ const response = await issueRequest(parsed, method, body, pinResult.pinned, maxFetchBytes, denial);
260
+ if (response.status >= 300 && response.status < 400) {
261
+ if (!response.location) {
262
+ throw denial('redirect response carried no Location header');
263
+ }
264
+ try {
265
+ currentUrl = new URL(response.location, currentUrl).toString();
266
+ }
267
+ catch {
268
+ throw denial('redirect response carried an unparseable Location header');
269
+ }
270
+ continue;
271
+ }
272
+ return { status: response.status, body: response.body };
273
+ }
274
+ throw denial(`exceeded ${MAX_REDIRECTS} redirects`);
275
+ }
276
+ return {
277
+ get: (url) => fetchAllowed(url, 'GET', undefined),
278
+ post: (url, requestBody) => fetchAllowed(url, 'POST', requestBody),
279
+ patch: (url, requestBody) => fetchAllowed(url, 'PATCH', requestBody),
280
+ };
281
+ }
282
+ /**
283
+ * An in-memory `CacheProvider` (`@markii/lua`) seeded from `snapshot` at
284
+ * construction and readable back out afterward via `.snapshot()` — the
285
+ * "snapshot-in/snapshot-out" design from the locked run-arc comment. No
286
+ * disk/IndexedDB access here: persisting the returned snapshot across runs
287
+ * is `run-host.ts`'s caller's job (extension storage, in the real
288
+ * extension).
289
+ */
@@ -0,0 +1,30 @@
1
+ import type { IsolateSpawner } from './isolate.js';
2
+ import type { SpawnRunOptions } from './run-host.js';
3
+ import type { RunResult } from './worker-entry.js';
4
+ /**
5
+ * The ONLY fallback this shared package supplies for an unset `workerPath`:
6
+ * the sibling `worker-entry.ts`, run straight from source via `tsx`
7
+ * (`TSX_DEV_EXEC_ARGV`, above). That is correct for this package's own
8
+ * dev/Vitest runs — `__dirname` at that point is this real source
9
+ * directory — but it says nothing about where a packaged host's bundled
10
+ * worker lives, because this package does not know that host's `dist/`
11
+ * layout. A packaged host (e.g. `apps/vscode`) MUST resolve its own bundled
12
+ * worker path itself and pass it as `SpawnRunOptions.workerPath`; see
13
+ * `apps/vscode/src/worker-path.ts` for that host's resolution and
14
+ * `apps/vscode/esbuild.config.mjs`'s worker build for where it bundles to.
15
+ */
16
+ export declare function defaultWorkerPath(): string;
17
+ /**
18
+ * The default isolate: a `node:worker_threads` worker, which is what every
19
+ * Node host (the VS Code extension host, this package's own tests) uses. It
20
+ * is the only kind that accepts `resourceLimits`, so it is also the only
21
+ * kind whose V8 heap is bounded — see `./isolate.ts` for why a host might
22
+ * still have to supply the other one.
23
+ */
24
+ export declare const workerThreadIsolate: IsolateSpawner;
25
+ /**
26
+ * `spawnRun` with the Node defaults filled in: the worker-thread isolate and
27
+ * the dev/Vitest worker entry. For this package's own tests and any Node
28
+ * caller that wants them; a packaged host passes its own `workerPath`.
29
+ */
30
+ export declare function spawnRunInThread(options: Omit<SpawnRunOptions, 'spawnIsolate' | 'workerPath'> & Partial<Pick<SpawnRunOptions, 'spawnIsolate' | 'workerPath'>>): Promise<RunResult>;