was-teaching-server 0.9.1

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 (295) hide show
  1. package/CHANGELOG.md +1432 -0
  2. package/LICENSE +661 -0
  3. package/README.md +305 -0
  4. package/common/css/materialize.1.0.0.min.css +13 -0
  5. package/dist/auth-header-hooks.d.ts +84 -0
  6. package/dist/auth-header-hooks.d.ts.map +1 -0
  7. package/dist/auth-header-hooks.js +136 -0
  8. package/dist/auth-header-hooks.js.map +1 -0
  9. package/dist/authorize.d.ts +45 -0
  10. package/dist/authorize.d.ts.map +1 -0
  11. package/dist/authorize.js +76 -0
  12. package/dist/authorize.js.map +1 -0
  13. package/dist/backends/filesystem.d.ts +1179 -0
  14. package/dist/backends/filesystem.d.ts.map +1 -0
  15. package/dist/backends/filesystem.js +2727 -0
  16. package/dist/backends/filesystem.js.map +1 -0
  17. package/dist/backends/postgres.d.ts +833 -0
  18. package/dist/backends/postgres.d.ts.map +1 -0
  19. package/dist/backends/postgres.js +2164 -0
  20. package/dist/backends/postgres.js.map +1 -0
  21. package/dist/backends/postgresSchema.d.ts +36 -0
  22. package/dist/backends/postgresSchema.d.ts.map +1 -0
  23. package/dist/backends/postgresSchema.js +203 -0
  24. package/dist/backends/postgresSchema.js.map +1 -0
  25. package/dist/config.default.d.ts +360 -0
  26. package/dist/config.default.d.ts.map +1 -0
  27. package/dist/config.default.js +493 -0
  28. package/dist/config.default.js.map +1 -0
  29. package/dist/corsProxy.d.ts +45 -0
  30. package/dist/corsProxy.d.ts.map +1 -0
  31. package/dist/corsProxy.js +278 -0
  32. package/dist/corsProxy.js.map +1 -0
  33. package/dist/digest.d.ts +45 -0
  34. package/dist/digest.d.ts.map +1 -0
  35. package/dist/digest.js +174 -0
  36. package/dist/digest.js.map +1 -0
  37. package/dist/errors.d.ts +654 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +960 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/atomicFile.d.ts +61 -0
  46. package/dist/lib/atomicFile.d.ts.map +1 -0
  47. package/dist/lib/atomicFile.js +150 -0
  48. package/dist/lib/atomicFile.js.map +1 -0
  49. package/dist/lib/backendRegistry.d.ts +59 -0
  50. package/dist/lib/backendRegistry.d.ts.map +1 -0
  51. package/dist/lib/backendRegistry.js +92 -0
  52. package/dist/lib/backendRegistry.js.map +1 -0
  53. package/dist/lib/backendUsage.d.ts +28 -0
  54. package/dist/lib/backendUsage.d.ts.map +1 -0
  55. package/dist/lib/backendUsage.js +55 -0
  56. package/dist/lib/backendUsage.js.map +1 -0
  57. package/dist/lib/backends.d.ts +142 -0
  58. package/dist/lib/backends.d.ts.map +1 -0
  59. package/dist/lib/backends.js +272 -0
  60. package/dist/lib/backends.js.map +1 -0
  61. package/dist/lib/blindedIndex.d.ts +156 -0
  62. package/dist/lib/blindedIndex.d.ts.map +1 -0
  63. package/dist/lib/blindedIndex.js +338 -0
  64. package/dist/lib/blindedIndex.js.map +1 -0
  65. package/dist/lib/collectionDescription.d.ts +33 -0
  66. package/dist/lib/collectionDescription.d.ts.map +1 -0
  67. package/dist/lib/collectionDescription.js +21 -0
  68. package/dist/lib/collectionDescription.js.map +1 -0
  69. package/dist/lib/cursor.d.ts +18 -0
  70. package/dist/lib/cursor.d.ts.map +1 -0
  71. package/dist/lib/cursor.js +50 -0
  72. package/dist/lib/cursor.js.map +1 -0
  73. package/dist/lib/edvEnvelope.d.ts +50 -0
  74. package/dist/lib/edvEnvelope.d.ts.map +1 -0
  75. package/dist/lib/edvEnvelope.js +106 -0
  76. package/dist/lib/edvEnvelope.js.map +1 -0
  77. package/dist/lib/encryption.d.ts +192 -0
  78. package/dist/lib/encryption.d.ts.map +1 -0
  79. package/dist/lib/encryption.js +383 -0
  80. package/dist/lib/encryption.js.map +1 -0
  81. package/dist/lib/etag.d.ts +33 -0
  82. package/dist/lib/etag.d.ts.map +1 -0
  83. package/dist/lib/etag.js +36 -0
  84. package/dist/lib/etag.js.map +1 -0
  85. package/dist/lib/exportManifest.d.ts +29 -0
  86. package/dist/lib/exportManifest.d.ts.map +1 -0
  87. package/dist/lib/exportManifest.js +77 -0
  88. package/dist/lib/exportManifest.js.map +1 -0
  89. package/dist/lib/importTar.d.ts +81 -0
  90. package/dist/lib/importTar.d.ts.map +1 -0
  91. package/dist/lib/importTar.js +301 -0
  92. package/dist/lib/importTar.js.map +1 -0
  93. package/dist/lib/isJson.d.ts +16 -0
  94. package/dist/lib/isJson.d.ts.map +1 -0
  95. package/dist/lib/isJson.js +17 -0
  96. package/dist/lib/isJson.js.map +1 -0
  97. package/dist/lib/keyEpoch.d.ts +38 -0
  98. package/dist/lib/keyEpoch.d.ts.map +1 -0
  99. package/dist/lib/keyEpoch.js +75 -0
  100. package/dist/lib/keyEpoch.js.map +1 -0
  101. package/dist/lib/keyedMutex.d.ts +28 -0
  102. package/dist/lib/keyedMutex.d.ts.map +1 -0
  103. package/dist/lib/keyedMutex.js +45 -0
  104. package/dist/lib/keyedMutex.js.map +1 -0
  105. package/dist/lib/kmsModule.d.ts +62 -0
  106. package/dist/lib/kmsModule.d.ts.map +1 -0
  107. package/dist/lib/kmsModule.js +327 -0
  108. package/dist/lib/kmsModule.js.map +1 -0
  109. package/dist/lib/kmsRecordCipher.d.ts +82 -0
  110. package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
  111. package/dist/lib/kmsRecordCipher.js +226 -0
  112. package/dist/lib/kmsRecordCipher.js.map +1 -0
  113. package/dist/lib/pagination.d.ts +19 -0
  114. package/dist/lib/pagination.d.ts.map +1 -0
  115. package/dist/lib/pagination.js +21 -0
  116. package/dist/lib/pagination.js.map +1 -0
  117. package/dist/lib/paths.d.ts +273 -0
  118. package/dist/lib/paths.d.ts.map +1 -0
  119. package/dist/lib/paths.js +270 -0
  120. package/dist/lib/paths.js.map +1 -0
  121. package/dist/lib/preconditions.d.ts +65 -0
  122. package/dist/lib/preconditions.d.ts.map +1 -0
  123. package/dist/lib/preconditions.js +112 -0
  124. package/dist/lib/preconditions.js.map +1 -0
  125. package/dist/lib/resourceFileName.d.ts +41 -0
  126. package/dist/lib/resourceFileName.d.ts.map +1 -0
  127. package/dist/lib/resourceFileName.js +58 -0
  128. package/dist/lib/resourceFileName.js.map +1 -0
  129. package/dist/lib/revocations.d.ts +49 -0
  130. package/dist/lib/revocations.d.ts.map +1 -0
  131. package/dist/lib/revocations.js +86 -0
  132. package/dist/lib/revocations.js.map +1 -0
  133. package/dist/lib/validateDid.d.ts +19 -0
  134. package/dist/lib/validateDid.d.ts.map +1 -0
  135. package/dist/lib/validateDid.js +35 -0
  136. package/dist/lib/validateDid.js.map +1 -0
  137. package/dist/lib/validateId.d.ts +66 -0
  138. package/dist/lib/validateId.d.ts.map +1 -0
  139. package/dist/lib/validateId.js +126 -0
  140. package/dist/lib/validateId.js.map +1 -0
  141. package/dist/plugin.d.ts +115 -0
  142. package/dist/plugin.d.ts.map +1 -0
  143. package/dist/plugin.js +134 -0
  144. package/dist/plugin.js.map +1 -0
  145. package/dist/policy.d.ts +78 -0
  146. package/dist/policy.d.ts.map +1 -0
  147. package/dist/policy.js +115 -0
  148. package/dist/policy.js.map +1 -0
  149. package/dist/provisioning.d.ts +38 -0
  150. package/dist/provisioning.d.ts.map +1 -0
  151. package/dist/provisioning.js +72 -0
  152. package/dist/provisioning.js.map +1 -0
  153. package/dist/requests/BackendRequest.d.ts +61 -0
  154. package/dist/requests/BackendRequest.d.ts.map +1 -0
  155. package/dist/requests/BackendRequest.js +152 -0
  156. package/dist/requests/BackendRequest.js.map +1 -0
  157. package/dist/requests/CollectionRequest.d.ts +234 -0
  158. package/dist/requests/CollectionRequest.d.ts.map +1 -0
  159. package/dist/requests/CollectionRequest.js +705 -0
  160. package/dist/requests/CollectionRequest.js.map +1 -0
  161. package/dist/requests/KeyRequest.d.ts +115 -0
  162. package/dist/requests/KeyRequest.d.ts.map +1 -0
  163. package/dist/requests/KeyRequest.js +468 -0
  164. package/dist/requests/KeyRequest.js.map +1 -0
  165. package/dist/requests/KeystoreRequest.d.ts +99 -0
  166. package/dist/requests/KeystoreRequest.d.ts.map +1 -0
  167. package/dist/requests/KeystoreRequest.js +247 -0
  168. package/dist/requests/KeystoreRequest.js.map +1 -0
  169. package/dist/requests/PolicyRequest.d.ts +53 -0
  170. package/dist/requests/PolicyRequest.d.ts.map +1 -0
  171. package/dist/requests/PolicyRequest.js +124 -0
  172. package/dist/requests/PolicyRequest.js.map +1 -0
  173. package/dist/requests/ResourceRequest.d.ts +134 -0
  174. package/dist/requests/ResourceRequest.d.ts.map +1 -0
  175. package/dist/requests/ResourceRequest.js +548 -0
  176. package/dist/requests/ResourceRequest.js.map +1 -0
  177. package/dist/requests/RevocationRequest.d.ts +67 -0
  178. package/dist/requests/RevocationRequest.d.ts.map +1 -0
  179. package/dist/requests/RevocationRequest.js +187 -0
  180. package/dist/requests/RevocationRequest.js.map +1 -0
  181. package/dist/requests/SpaceRequest.d.ts +204 -0
  182. package/dist/requests/SpaceRequest.d.ts.map +1 -0
  183. package/dist/requests/SpaceRequest.js +520 -0
  184. package/dist/requests/SpaceRequest.js.map +1 -0
  185. package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
  186. package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
  187. package/dist/requests/SpacesRepositoryRequest.js +159 -0
  188. package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
  189. package/dist/requests/collectionContext.d.ts +21 -0
  190. package/dist/requests/collectionContext.d.ts.map +1 -0
  191. package/dist/requests/collectionContext.js +30 -0
  192. package/dist/requests/collectionContext.js.map +1 -0
  193. package/dist/requests/controllerConsent.d.ts +58 -0
  194. package/dist/requests/controllerConsent.d.ts.map +1 -0
  195. package/dist/requests/controllerConsent.js +73 -0
  196. package/dist/requests/controllerConsent.js.map +1 -0
  197. package/dist/requests/keystoreContext.d.ts +68 -0
  198. package/dist/requests/keystoreContext.d.ts.map +1 -0
  199. package/dist/requests/keystoreContext.js +87 -0
  200. package/dist/requests/keystoreContext.js.map +1 -0
  201. package/dist/requests/resourceInput.d.ts +19 -0
  202. package/dist/requests/resourceInput.d.ts.map +1 -0
  203. package/dist/requests/resourceInput.js +102 -0
  204. package/dist/requests/resourceInput.js.map +1 -0
  205. package/dist/requests/spaceContext.d.ts +127 -0
  206. package/dist/requests/spaceContext.d.ts.map +1 -0
  207. package/dist/requests/spaceContext.js +201 -0
  208. package/dist/requests/spaceContext.js.map +1 -0
  209. package/dist/routes.d.ts +55 -0
  210. package/dist/routes.d.ts.map +1 -0
  211. package/dist/routes.js +306 -0
  212. package/dist/routes.js.map +1 -0
  213. package/dist/server.d.ts +20 -0
  214. package/dist/server.d.ts.map +1 -0
  215. package/dist/server.js +60 -0
  216. package/dist/server.js.map +1 -0
  217. package/dist/start.d.ts +7 -0
  218. package/dist/start.d.ts.map +1 -0
  219. package/dist/start.js +61 -0
  220. package/dist/start.js.map +1 -0
  221. package/dist/storage.d.ts +29 -0
  222. package/dist/storage.d.ts.map +1 -0
  223. package/dist/storage.js +47 -0
  224. package/dist/storage.js.map +1 -0
  225. package/dist/types.d.ts +915 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/views/home.hbs +9 -0
  230. package/dist/views/templates/main.hbs +12 -0
  231. package/dist/zcap.d.ts +267 -0
  232. package/dist/zcap.d.ts.map +1 -0
  233. package/dist/zcap.js +478 -0
  234. package/dist/zcap.js.map +1 -0
  235. package/package.json +109 -0
  236. package/src/auth-header-hooks.ts +163 -0
  237. package/src/authorize.ts +117 -0
  238. package/src/backends/filesystem.ts +3626 -0
  239. package/src/backends/postgres.ts +3073 -0
  240. package/src/backends/postgresSchema.ts +231 -0
  241. package/src/config.default.ts +657 -0
  242. package/src/corsProxy.ts +326 -0
  243. package/src/declarations.d.ts +28 -0
  244. package/src/digest.ts +223 -0
  245. package/src/errors.ts +1122 -0
  246. package/src/index.ts +21 -0
  247. package/src/lib/atomicFile.ts +176 -0
  248. package/src/lib/backendRegistry.ts +139 -0
  249. package/src/lib/backendUsage.ts +79 -0
  250. package/src/lib/backends.ts +351 -0
  251. package/src/lib/blindedIndex.ts +466 -0
  252. package/src/lib/collectionDescription.ts +43 -0
  253. package/src/lib/cursor.ts +52 -0
  254. package/src/lib/edvEnvelope.ts +120 -0
  255. package/src/lib/encryption.ts +472 -0
  256. package/src/lib/etag.ts +40 -0
  257. package/src/lib/exportManifest.ts +115 -0
  258. package/src/lib/importTar.ts +381 -0
  259. package/src/lib/isJson.ts +18 -0
  260. package/src/lib/keyEpoch.ts +89 -0
  261. package/src/lib/keyedMutex.ts +45 -0
  262. package/src/lib/kmsModule.ts +451 -0
  263. package/src/lib/kmsRecordCipher.ts +292 -0
  264. package/src/lib/pagination.ts +22 -0
  265. package/src/lib/paths.ts +382 -0
  266. package/src/lib/preconditions.ts +145 -0
  267. package/src/lib/resourceFileName.ts +69 -0
  268. package/src/lib/revocations.ts +116 -0
  269. package/src/lib/validateDid.ts +41 -0
  270. package/src/lib/validateId.ts +148 -0
  271. package/src/plugin.ts +288 -0
  272. package/src/policy.ts +176 -0
  273. package/src/provisioning.ts +98 -0
  274. package/src/requests/BackendRequest.ts +219 -0
  275. package/src/requests/CollectionRequest.ts +899 -0
  276. package/src/requests/KeyRequest.ts +617 -0
  277. package/src/requests/KeystoreRequest.ts +325 -0
  278. package/src/requests/PolicyRequest.ts +169 -0
  279. package/src/requests/ResourceRequest.ts +649 -0
  280. package/src/requests/RevocationRequest.ts +267 -0
  281. package/src/requests/SpaceRequest.ts +686 -0
  282. package/src/requests/SpacesRepositoryRequest.ts +194 -0
  283. package/src/requests/collectionContext.ts +41 -0
  284. package/src/requests/controllerConsent.ts +108 -0
  285. package/src/requests/keystoreContext.ts +124 -0
  286. package/src/requests/resourceInput.ts +118 -0
  287. package/src/requests/spaceContext.ts +311 -0
  288. package/src/routes.ts +420 -0
  289. package/src/server.ts +69 -0
  290. package/src/start.ts +71 -0
  291. package/src/storage.ts +60 -0
  292. package/src/types.ts +1016 -0
  293. package/src/views/home.hbs +9 -0
  294. package/src/views/templates/main.hbs +12 -0
  295. package/src/zcap.ts +657 -0
@@ -0,0 +1,326 @@
1
+ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'
2
+ import type { LookupFunction } from 'node:net'
3
+ import net from 'node:net'
4
+ import { lookup as dnsLookup } from 'node:dns/promises'
5
+ import { fetch, Agent } from 'undici'
6
+
7
+ const PROXY_TIMEOUT_MS = 10_000
8
+ const MAX_REDIRECTS = 5
9
+
10
+ /**
11
+ * Upper bound on the response body the proxy will buffer and relay. Caps memory
12
+ * use per proxied request and refuses to relay an oversized upstream response
13
+ * (enforced both from a declared `content-length` and while streaming).
14
+ */
15
+ const PROXY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
16
+
17
+ interface CorsProxyQuery {
18
+ url?: string
19
+ }
20
+
21
+ /**
22
+ * True for an IPv4 address in a private, loopback, link-local, or otherwise
23
+ * non-public range -- the SSRF-sensitive destinations the proxy must refuse
24
+ * (RFC 1918 private space, `127.0.0.0/8` loopback, `169.254.0.0/16` link-local
25
+ * -- which covers the `169.254.169.254` cloud-metadata endpoint -- CGNAT, and
26
+ * multicast/reserved). A syntactically invalid address is treated as blocked.
27
+ * @param ip {string}
28
+ * @returns {boolean}
29
+ */
30
+ function isBlockedIpv4(ip: string): boolean {
31
+ const parts = ip.split('.').map(Number)
32
+ if (
33
+ parts.length !== 4 ||
34
+ parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)
35
+ ) {
36
+ return true
37
+ }
38
+ const [a, b] = parts as [number, number, number, number]
39
+ return (
40
+ a === 0 || // 0.0.0.0/8 "this network"
41
+ a === 10 || // private
42
+ a === 127 || // loopback
43
+ (a === 169 && b === 254) || // link-local (incl. cloud metadata)
44
+ (a === 172 && b >= 16 && b <= 31) || // private
45
+ (a === 192 && b === 168) || // private
46
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT (RFC 6598)
47
+ (a === 192 && b === 0) || // 192.0.0.0/24 IETF protocol assignments
48
+ a >= 224 // multicast / reserved
49
+ )
50
+ }
51
+
52
+ /**
53
+ * True for an IPv6 address in a loopback, unspecified, unique-local (`fc00::/7`),
54
+ * or link-local (`fe80::/10`) range, or an IPv4-mapped/embedded address whose
55
+ * IPv4 form is blocked. A syntactically invalid address is treated as blocked.
56
+ * @param ip {string}
57
+ * @returns {boolean}
58
+ */
59
+ function isBlockedIpv6(ip: string): boolean {
60
+ const lower = ip.toLowerCase()
61
+ const mapped = lower.match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/)
62
+ if (mapped) {
63
+ return isBlockedIpv4(mapped[1]!)
64
+ }
65
+ return (
66
+ lower === '::' ||
67
+ lower === '::1' ||
68
+ lower.startsWith('fe8') || // fe80::/10 link-local
69
+ lower.startsWith('fe9') ||
70
+ lower.startsWith('fea') ||
71
+ lower.startsWith('feb') ||
72
+ lower.startsWith('fc') || // fc00::/7 unique-local
73
+ lower.startsWith('fd')
74
+ )
75
+ }
76
+
77
+ /**
78
+ * True for an IP the proxy must not reach. Unparseable input is blocked
79
+ * defensively.
80
+ * @param ip {string}
81
+ * @returns {boolean}
82
+ */
83
+ function isBlockedIp(ip: string): boolean {
84
+ const family = net.isIP(ip)
85
+ if (family === 4) {
86
+ return isBlockedIpv4(ip)
87
+ }
88
+ if (family === 6) {
89
+ return isBlockedIpv6(ip)
90
+ }
91
+ return true
92
+ }
93
+
94
+ /**
95
+ * Parses and validates a single proxy target: `http`/`https` scheme only, and
96
+ * the host must not resolve to a blocked (private / loopback / link-local)
97
+ * address. Runs once for the client-supplied URL and again for every redirect
98
+ * hop, so an allowed public host cannot bounce the proxy to an internal one.
99
+ * Returns the exact addresses it validated so the caller can pin the upstream
100
+ * connection to them (closing the DNS-rebinding TOCTOU).
101
+ * @param target {string}
102
+ * @returns {Promise<{url: URL, addresses: {address: string, family: number}[]} | {status: number, error: string}>}
103
+ * the parsed URL plus its validated addresses when allowed, or the HTTP
104
+ * status + message to reject with.
105
+ */
106
+ async function checkProxyTarget(
107
+ target: string
108
+ ): Promise<
109
+ | { url: URL; addresses: { address: string; family: number }[] }
110
+ | { status: number; error: string }
111
+ > {
112
+ let parsed: URL
113
+ try {
114
+ parsed = new URL(target)
115
+ } catch {
116
+ return { status: 400, error: 'Invalid url query parameter' }
117
+ }
118
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
119
+ return {
120
+ status: 400,
121
+ error: 'Only http and https URLs may be proxied.'
122
+ }
123
+ }
124
+
125
+ // Resolve the host and refuse private / loopback / link-local destinations
126
+ // (SSRF). `dns.lookup` returns the literal itself for an IP host, so
127
+ // IP-literal targets are covered too.
128
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '')
129
+ let addresses: { address: string; family: number }[]
130
+ try {
131
+ addresses = await dnsLookup(hostname, { all: true })
132
+ } catch {
133
+ return { status: 502, error: 'Unable to resolve proxied host.' }
134
+ }
135
+ if (
136
+ addresses.length === 0 ||
137
+ addresses.some(({ address }) => isBlockedIp(address))
138
+ ) {
139
+ return { status: 403, error: 'Proxying to this host is not allowed.' }
140
+ }
141
+ return { url: parsed, addresses }
142
+ }
143
+
144
+ /**
145
+ * Builds the undici `connect.lookup` function that pins each upstream socket to
146
+ * the exact addresses `checkProxyTarget` already validated, keyed by hostname.
147
+ * The `fetch` still connects using the original hostname (so TLS certificate
148
+ * validation and SNI keep working) but resolves it only via this map, so a
149
+ * rebinding attacker cannot swap in a fresh, private address between validation
150
+ * and connection. A hostname absent from the map is refused (defense in depth:
151
+ * the agent must never resolve an unpinned host).
152
+ * @param pins {Map<string, {address: string, family: number}[]>} validated
153
+ * addresses keyed by lower-cased hostname.
154
+ * @returns {LookupFunction} an undici/`net`-compatible lookup callback.
155
+ */
156
+ export function createPinnedLookup(
157
+ pins: Map<string, { address: string; family: number }[]>
158
+ ): LookupFunction {
159
+ return function pinnedLookup(
160
+ hostname: string,
161
+ options: { all?: boolean },
162
+ callback: (
163
+ err: NodeJS.ErrnoException | null,
164
+ addressOrAddresses?: string | { address: string; family: number }[],
165
+ family?: number
166
+ ) => void
167
+ ): void {
168
+ const entries = pins.get(hostname.toLowerCase())
169
+ if (!entries || entries.length === 0) {
170
+ callback(new Error(`Refusing to resolve unpinned host: ${hostname}`))
171
+ return
172
+ }
173
+ if (options.all) {
174
+ callback(null, entries)
175
+ return
176
+ }
177
+ const first = entries[0]!
178
+ callback(null, first.address, first.family)
179
+ } as unknown as LookupFunction
180
+ }
181
+
182
+ /**
183
+ * Registers a server-side CORS proxy at `/api/cors`.
184
+ *
185
+ * Example:
186
+ * `/api/cors?url=https%3A%2F%2Fregistry.dcconsortium.org%2F.well-known%2Fopenid-federation`
187
+ *
188
+ * SSRF guard: only `http`/`https` URLs are proxied, and the target host is
189
+ * resolved and rejected when it maps to a private, loopback, or link-local
190
+ * address (e.g. `http://169.254.169.254/...` cloud-metadata, or an internal
191
+ * service). Redirects are followed manually (up to `MAX_REDIRECTS` hops) and
192
+ * every hop is re-validated the same way, so a public host cannot 3xx the
193
+ * proxy into an internal one. The addresses each hop validated are then pinned
194
+ * for the actual upstream connection (via a per-request undici `Agent` whose
195
+ * `connect.lookup` resolves only from that pin map), so a DNS-rebinding
196
+ * attacker cannot pass validation with a public IP and have the subsequent
197
+ * `fetch` re-resolve to a private one (closing the TOCTOU). The relayed
198
+ * response body is capped at `PROXY_MAX_RESPONSE_BYTES`, both from a declared
199
+ * `content-length` and while streaming. This is an unauthenticated open
200
+ * endpoint; adding a lightweight auth gate (a shared secret header, an
201
+ * allowlist of caller origins, or the server's own capability-invocation
202
+ * check) is a reasonable follow-up.
203
+ *
204
+ * @param app - Fastify instance
205
+ * @param _options - Fastify plugin options
206
+ */
207
+ export async function initCorsProxyRoutes(
208
+ app: FastifyInstance,
209
+ _options: FastifyPluginOptions
210
+ ): Promise<void> {
211
+ app.get<{ Querystring: CorsProxyQuery }>(
212
+ '/api/cors',
213
+ async (request, reply) => {
214
+ const target = request.query.url
215
+
216
+ if (!target) {
217
+ return reply.code(400).send({ error: 'Missing url query parameter' })
218
+ }
219
+
220
+ const checked = await checkProxyTarget(target)
221
+ if (!('url' in checked)) {
222
+ return reply.code(checked.status).send({ error: checked.error })
223
+ }
224
+ let current = checked.url
225
+
226
+ const headers: Record<string, string> = {}
227
+ if (typeof request.headers.accept === 'string') {
228
+ headers.accept = request.headers.accept
229
+ }
230
+
231
+ // One undici Agent per proxied request, resolving each hop only via the
232
+ // addresses that hop already validated. The Agent must stay alive until
233
+ // the body is fully read, so it is torn down once in the finally below.
234
+ const pins = new Map<string, { address: string; family: number }[]>()
235
+ const pinKey = (url: URL) =>
236
+ url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
237
+ pins.set(pinKey(current), checked.addresses)
238
+ const agent = new Agent({ connect: { lookup: createPinnedLookup(pins) } })
239
+
240
+ try {
241
+ for (let hop = 0; ; hop++) {
242
+ const upstream = await fetch(current.href, {
243
+ headers,
244
+ redirect: 'manual',
245
+ signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
246
+ dispatcher: agent
247
+ })
248
+
249
+ const location = upstream.headers.get('location')
250
+ if (upstream.status >= 300 && upstream.status < 400 && location) {
251
+ if (hop >= MAX_REDIRECTS) {
252
+ return reply
253
+ .code(502)
254
+ .send({ error: 'Too many proxied redirects.' })
255
+ }
256
+ let next: URL
257
+ try {
258
+ next = new URL(location, current)
259
+ } catch {
260
+ return reply
261
+ .code(502)
262
+ .send({ error: 'Invalid redirect from proxied URL.' })
263
+ }
264
+ const nextChecked = await checkProxyTarget(next.toString())
265
+ if (!('url' in nextChecked)) {
266
+ return reply
267
+ .code(nextChecked.status)
268
+ .send({ error: nextChecked.error })
269
+ }
270
+ current = nextChecked.url
271
+ pins.set(pinKey(current), nextChecked.addresses)
272
+ continue
273
+ }
274
+
275
+ // Refuse an upstream that declares a body larger than the cap before
276
+ // reading any of it.
277
+ const declaredLength = Number(upstream.headers.get('content-length'))
278
+ if (
279
+ Number.isFinite(declaredLength) &&
280
+ declaredLength > PROXY_MAX_RESPONSE_BYTES
281
+ ) {
282
+ return reply
283
+ .code(502)
284
+ .send({ error: 'Proxied response too large.' })
285
+ }
286
+
287
+ // Read incrementally so an undeclared (or lying) oversized body is
288
+ // stopped mid-stream rather than fully buffered. Upstream headers
289
+ // are copied only after the body is fully read, so a mid-stream
290
+ // rejection does not carry the upstream's content-type /
291
+ // content-length on the JSON error response.
292
+ const chunks: Buffer[] = []
293
+ if (upstream.body) {
294
+ const reader = upstream.body.getReader()
295
+ let total = 0
296
+ for (;;) {
297
+ const { done, value } = await reader.read()
298
+ if (done) {
299
+ break
300
+ }
301
+ total += value.byteLength
302
+ if (total > PROXY_MAX_RESPONSE_BYTES) {
303
+ await reader.cancel()
304
+ return reply
305
+ .code(502)
306
+ .send({ error: 'Proxied response too large.' })
307
+ }
308
+ chunks.push(Buffer.from(value))
309
+ }
310
+ }
311
+ upstream.headers.forEach((value, name) => reply.header(name, value))
312
+ return reply.code(upstream.status).send(Buffer.concat(chunks))
313
+ }
314
+ } catch (error) {
315
+ request.log.warn({ error, target }, 'CORS proxy fetch failed')
316
+ return reply.code(502).send({ error: 'Unable to fetch proxied URL' })
317
+ } finally {
318
+ // destroy(), not close(): early-exit paths (blocked redirect, too
319
+ // many redirects, oversized content-length) leave upstream bodies
320
+ // unconsumed, and close() would wait on them. Everything the reply
321
+ // sends is already buffered, so tearing down the sockets is safe.
322
+ await agent.destroy()
323
+ }
324
+ }
325
+ )
326
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Ambient module declarations for runtime dependencies that do not ship their
3
+ * own types and lack an `@types/*` package.
4
+ *
5
+ * Minimal ambient declarations covering only the surface this codebase uses,
6
+ * for runtime dependencies that do not ship their own types and lack an
7
+ * `@types/*` package.
8
+ */
9
+
10
+ declare module 'fs-json-store' {
11
+ export interface StoreOptions {
12
+ file: string
13
+ }
14
+
15
+ /** Reads/writes a single JSON document atomically to `options.file`. */
16
+ export class Store<T = unknown> {
17
+ constructor(options: StoreOptions)
18
+ /** Resolves the parsed document, or `undefined` if the file is absent. */
19
+ read(): Promise<T | undefined>
20
+ /** Writes the document and resolves the written value. */
21
+ write(data: T): Promise<T>
22
+ }
23
+
24
+ const _default: { Store: typeof Store }
25
+ export default _default
26
+ }
27
+
28
+ export {}
package/src/digest.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Request Body Integrity (spec "Request Body Integrity (Digest Header)"): binds
3
+ * a request body to its HTTP Signature via the `Digest` header. Two hooks,
4
+ * installed by every route group alongside the auth hooks:
5
+ *
6
+ * - `captureRawBody` (preParsing) tees the exact body bytes onto
7
+ * `request.rawBody` for JSON/text bodies, so the digest can be recomputed
8
+ * against what the client actually signed. Re-serializing the parsed body is
9
+ * not guaranteed byte-identical, so we keep the raw bytes instead. Streamed
10
+ * bodies (multipart uploads, tar imports, raw blobs) are not buffered; instead
11
+ * they pass through a hashing transform that verifies the `Digest`
12
+ * incrementally at end-of-stream, so large/binary uploads keep streaming yet
13
+ * are still bound to the signature.
14
+ * - `verifyBodyDigest` (preValidation) enforces, for any request carrying a
15
+ * `Content-Type`: that the signature covers the `digest` header (MUST), that a
16
+ * `Digest` header is present, and -- when the raw body was captured --
17
+ * independently recomputes the body digest and compares it (SHOULD). A
18
+ * missing, malformed, uncovered, or non-matching digest is rejected with
19
+ * `invalid-authorization-header` (400).
20
+ */
21
+ import type { FastifyReply, FastifyRequest } from 'fastify'
22
+ import { verifyHeaderValue } from '@interop/http-digest-header'
23
+ import { PassThrough, Transform, type Readable } from 'node:stream'
24
+ import { createHash } from 'node:crypto'
25
+ import { isJson } from './lib/isJson.js'
26
+ import { InvalidDigestError } from './errors.js'
27
+
28
+ /**
29
+ * True for the body shapes `verifyBodyDigest` recomputes: JSON
30
+ * (`application/json` / `+json`) and text. These reach handlers fully parsed
31
+ * (object / string), so buffering their bytes is cheap; streamed bodies
32
+ * (multipart, `application/x-tar`) are deliberately excluded.
33
+ * @param contentType {string | undefined}
34
+ * @returns {boolean}
35
+ */
36
+ function isBufferableBody(contentType: string | undefined): boolean {
37
+ return (
38
+ isJson({ contentType }) ||
39
+ (typeof contentType === 'string' && contentType.startsWith('text/'))
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Compares an incrementally-computed SHA-256 digest against a `Digest` header
45
+ * value, mirroring `@interop/http-digest-header`'s encodings without buffering
46
+ * the body: the multihash form (`mh=u<base64url(0x12 0x20 <digest>)>`, what WAS
47
+ * clients send) and the RFC 9651 `SHA-256=<base64>` form (colons stripped).
48
+ * @param options {object}
49
+ * @param options.sha256Digest {Buffer} the computed 32-byte SHA-256 digest
50
+ * @param options.headerValue {string} the request's `Digest` header value
51
+ * @returns {boolean}
52
+ */
53
+ function digestMatches({
54
+ sha256Digest,
55
+ headerValue
56
+ }: {
57
+ sha256Digest: Buffer
58
+ headerValue: string
59
+ }): boolean {
60
+ const [key = '', rawValue] = headerValue.split(/=(.+)/)
61
+ if (key === 'mh') {
62
+ const multihash = Buffer.concat([Buffer.from([0x12, 0x20]), sha256Digest])
63
+ return rawValue === `u${multihash.toString('base64url')}`
64
+ }
65
+ if (key.replace('-', '').toLowerCase() === 'sha256') {
66
+ const expected = (rawValue ?? '').replace(/^:(.*):$/, '$1')
67
+ return expected === sha256Digest.toString('base64')
68
+ }
69
+ // Only SHA-256 is supported (as in the library); anything else cannot match.
70
+ return false
71
+ }
72
+
73
+ /**
74
+ * A pass-through Transform that hashes the streamed bytes (SHA-256) and, at
75
+ * end-of-stream, verifies the accumulated digest against the request's `Digest`
76
+ * header -- erroring the stream (which fails the write) on a mismatch. This
77
+ * binds a streamed/binary body to its signed `Digest` without buffering it. A
78
+ * mismatch surfaces mid-write; consumers that persist the stream remove any
79
+ * partial output on the resulting error (see the filesystem backend's blob
80
+ * write).
81
+ */
82
+ class DigestVerifyStream extends Transform {
83
+ private readonly _hash = createHash('sha256')
84
+ private readonly _digestHeader: string
85
+ constructor(digestHeader: string) {
86
+ super()
87
+ this._digestHeader = digestHeader
88
+ }
89
+ override _transform(
90
+ chunk: Buffer,
91
+ _encoding: BufferEncoding,
92
+ callback: (error?: Error | null, data?: Buffer) => void
93
+ ): void {
94
+ this._hash.update(chunk)
95
+ callback(null, chunk)
96
+ }
97
+ override _flush(callback: (error?: Error | null) => void): void {
98
+ const matched = digestMatches({
99
+ sha256Digest: this._hash.digest(),
100
+ headerValue: this._digestHeader
101
+ })
102
+ callback(
103
+ matched
104
+ ? null
105
+ : new InvalidDigestError({
106
+ detail: 'The `Digest` header does not match the request body.'
107
+ })
108
+ )
109
+ }
110
+ }
111
+
112
+ /**
113
+ * preParsing hook: for JSON/text bodies, tees the incoming payload into
114
+ * `request.rawBody` while passing the same bytes through to Fastify's content-
115
+ * type parser (so `verifyBodyDigest` can recompute the digest). Other (streamed)
116
+ * bodies are not buffered; when the request is signed with a `Digest` they pass
117
+ * through a `DigestVerifyStream` that verifies that digest incrementally, so a
118
+ * swapped binary/large body under a valid signed `Digest` is still rejected.
119
+ * @param request {import('fastify').FastifyRequest}
120
+ * @param reply {import('fastify').FastifyReply}
121
+ * @param payload {Readable} the raw request body stream
122
+ * @returns {Promise<Readable>} the stream Fastify should parse
123
+ */
124
+ export async function captureRawBody(
125
+ request: FastifyRequest,
126
+ _reply: FastifyReply,
127
+ payload: Readable
128
+ ): Promise<Readable> {
129
+ if (!isBufferableBody(request.headers['content-type'])) {
130
+ // Streamed (non-buffered) body. Bind it to the signature by hashing it as it
131
+ // flows; an unsigned/anonymous body (no `Digest`) has nothing to verify.
132
+ // `parseAuthHeaders` (onRequest) has already run, so `request.zcap` is set
133
+ // for a signed request.
134
+ const digestHeader = request.zcap?.digest
135
+ if (!digestHeader) {
136
+ return payload
137
+ }
138
+ const verify = new DigestVerifyStream(digestHeader)
139
+ payload.on('error', err => verify.destroy(err))
140
+ return payload.pipe(verify)
141
+ }
142
+ const chunks: Buffer[] = []
143
+ const passthrough = new PassThrough()
144
+ payload.on('data', chunk => {
145
+ chunks.push(chunk as Buffer)
146
+ passthrough.write(chunk)
147
+ })
148
+ payload.on('end', () => {
149
+ request.rawBody = Buffer.concat(chunks)
150
+ passthrough.end()
151
+ })
152
+ payload.on('error', err => passthrough.destroy(err))
153
+ return passthrough
154
+ }
155
+
156
+ /**
157
+ * preValidation hook: enforces the `Digest` header binding for any request that
158
+ * carries a `Content-Type`. Bodyless requests (no `Content-Type`) and anonymous
159
+ * reads (no parsed `request.zcap`) are passed through.
160
+ * @param request {import('fastify').FastifyRequest}
161
+ * @param reply {import('fastify').FastifyReply}
162
+ * @returns {Promise<void>}
163
+ */
164
+ export async function verifyBodyDigest(
165
+ request: FastifyRequest,
166
+ _reply: FastifyReply
167
+ ): Promise<void> {
168
+ // A provisioning-authorized request (onboarding token) is not signed, so
169
+ // there is no signature to cover a `Digest` header -- nothing to bind.
170
+ if (request.provisioningAuthorized) {
171
+ return
172
+ }
173
+ const contentType = request.headers['content-type']
174
+ // Bodyless requests carry no Content-Type and no Digest -- nothing to bind.
175
+ if (!contentType) {
176
+ return
177
+ }
178
+ // No parsed auth headers: writes require auth (the auth hooks 401 first), so a
179
+ // bodied request reaching here without `zcap` is a safe method that happens to
180
+ // carry a Content-Type; leave it to the handler's policy decision.
181
+ const { zcap } = request
182
+ if (!zcap) {
183
+ return
184
+ }
185
+
186
+ // MUST: `digest` is among the signature's covered (signed) headers. Guard the
187
+ // `headers` param itself: an `Authorization` header that omits `headers="..."`
188
+ // leaves it undefined, and splitting that would be a 500 rather than the
189
+ // intended `invalid-authorization-header` (400).
190
+ if (!zcap.headers || !zcap.headers.split(/\s+/).includes('digest')) {
191
+ throw new InvalidDigestError({
192
+ detail:
193
+ 'The request signature must cover the `digest` header when the ' +
194
+ 'request carries a body.'
195
+ })
196
+ }
197
+ // The `Digest` header itself MUST be present.
198
+ if (!zcap.digest) {
199
+ throw new InvalidDigestError({
200
+ detail: 'A `Digest` header is required when the request carries a body.'
201
+ })
202
+ }
203
+
204
+ // SHOULD: independently recompute and compare, when the raw body was captured.
205
+ // Streamed bodies (multipart, tar import, raw blobs) are not buffered here;
206
+ // `captureRawBody` has already wrapped them in a `DigestVerifyStream` that
207
+ // verifies the digest incrementally as the body is consumed.
208
+ if (request.rawBody === undefined) {
209
+ return
210
+ }
211
+ const { verified, error } = await verifyHeaderValue({
212
+ data: request.rawBody,
213
+ headerValue: zcap.digest
214
+ })
215
+ if (!verified) {
216
+ throw new InvalidDigestError({
217
+ detail: error
218
+ ? 'The `Digest` header is malformed or uses an unsupported algorithm.'
219
+ : 'The `Digest` header does not match the request body.',
220
+ cause: error
221
+ })
222
+ }
223
+ }