@tetherto/mdk-gateway 0.0.0 → 0.12.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.
@@ -0,0 +1,252 @@
1
+ 'use strict'
2
+
3
+ // HTTP hardening for the gateway: which callers reach an `auth: true` route,
4
+ // which Host headers are served, and the Fastify-level limits. Every option is
5
+ // read from the worker ctx first (what startGateway passes) and then from the
6
+ // gateway's common.json, so both programmatic and file-configured gateways get
7
+ // the same switches. Defaults are the safe side of each.
8
+
9
+ const DEFAULT_BODY_LIMIT = 1048576 // 1 MiB, Fastify's own default made explicit
10
+ const DEFAULT_RATE_LIMIT = {
11
+ max: 300,
12
+ timeWindow: '1 minute',
13
+ // An ERR_* error, so errorResponse forwards the code instead of masking and
14
+ // logging every 429 as an unexpected failure.
15
+ errorResponseBuilder: (request, context) => Object.assign(new Error('ERR_RATE_LIMITED'), { statusCode: context.statusCode })
16
+ }
17
+ // Helmet's defaults, minus upgrade-insecure-requests: a UI served from
18
+ // staticRootPath over plain http on a LAN address would otherwise have every
19
+ // asset rewritten to an https URL nothing answers.
20
+ const DEFAULT_HELMET = {
21
+ contentSecurityPolicy: { directives: { upgradeInsecureRequests: null } }
22
+ }
23
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
24
+ const WILDCARD_BIND = new Set(['0.0.0.0', '::', '[::]'])
25
+
26
+ // The permission an identity must carry for `?overwriteCache=true` to skip a
27
+ // cached response. Only an identity the authVerifier returned can carry it.
28
+ const OVERWRITE_CACHE_PERM = 'cache:overwrite'
29
+
30
+ function _pick (name, conf, ctx) {
31
+ if (ctx && ctx[name] !== undefined) return ctx[name]
32
+ if (conf && conf[name] !== undefined) return conf[name]
33
+ return undefined
34
+ }
35
+
36
+ // Off unless set. Anything Fastify accepts passes through (a hop count, an
37
+ // address list); only the CLI's 'true'/'false' strings are turned into booleans.
38
+ function _trustProxy (value) {
39
+ if (value === undefined || value === null || value === 'false') return false
40
+ if (value === 'true') return true
41
+ return value
42
+ }
43
+
44
+ // Built-in verifier for single-machine stacks (`mdk run`, the examples): a caller
45
+ // whose TCP peer is loopback is the local operator, the identity the agent plugin
46
+ // already falls back to. It reads the socket, never X-Forwarded-For, so it must not
47
+ // be used behind a reverse proxy on the same host (every proxied request would be
48
+ // loopback). Remote callers get 401 and need a real verifier.
49
+ //
50
+ // A loopback socket is not enough on its own: any page open in the operator's
51
+ // browser can fire a no-cors POST at http://localhost:<port>/..., and the Host
52
+ // check passes. So a browser request must also come from a loopback origin (the
53
+ // UI on localhost, or a Vite dev server on another port, which forwards the
54
+ // browser's Origin through its proxy) or, while the Host allowlist is on, from
55
+ // this gateway's own origin. Clients that send neither Origin nor
56
+ // Sec-Fetch-Site (curl, Node, the CLI) are unaffected.
57
+ //
58
+ // The same-origin case trusts request.host, which only the Host allowlist
59
+ // vouches for: with allowedHosts "*" a DNS-rebound page (Origin and Host both
60
+ // http://attacker.example:<port>) would otherwise look same-origin. So with the
61
+ // Host check off only loopback Origins are accepted.
62
+ const LOCAL_IDENTITY = Object.freeze({ user: Object.freeze({ userId: 'local' }) })
63
+ const LOOPBACK_HOSTNAMES = new Set(LOOPBACK_HOSTS)
64
+
65
+ function _isLoopbackAddress (addr) {
66
+ if (!addr) return false
67
+ const a = addr.startsWith('::ffff:') ? addr.slice(7) : addr
68
+ return a === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a)
69
+ }
70
+
71
+ function _isLocalBrowserCaller (request, trustSameOrigin) {
72
+ const headers = request.headers || {}
73
+ if (headers['sec-fetch-site'] === 'cross-site') return false
74
+
75
+ const origin = headers.origin
76
+ if (origin === undefined) return true
77
+ let url
78
+ try {
79
+ url = new URL(origin) // 'null' (sandboxed iframe, file://) throws and is refused
80
+ } catch {
81
+ return false
82
+ }
83
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
84
+ if (LOOPBACK_HOSTNAMES.has(url.hostname)) return true
85
+ if (!trustSameOrigin) return false
86
+ // Same origin as the request; request.host has already passed the Host allowlist.
87
+ const host = request.host ?? headers.host
88
+ return typeof host === 'string' && url.host === host.toLowerCase()
89
+ }
90
+
91
+ // hostCheckActive: whether the Host allowlist runs before the verifier. Only
92
+ // then is a non-loopback same-origin Origin accepted.
93
+ function createLoopbackVerifier ({ hostCheckActive = false } = {}) {
94
+ const trustSameOrigin = hostCheckActive === true
95
+ return async function loopbackVerifier (request) {
96
+ if (!_isLoopbackAddress(request.socket?.remoteAddress)) return false
97
+ return _isLocalBrowserCaller(request, trustSameOrigin) ? { ...LOCAL_IDENTITY } : false
98
+ }
99
+ }
100
+
101
+ // The strict variant (loopback Origins only), safe whatever the Host check.
102
+ // resolveSecurityOptions swaps in the same-origin-aware one when the allowlist is on.
103
+ const loopbackVerifier = createLoopbackVerifier()
104
+ const loopbackVerifierHostChecked = createLoopbackVerifier({ hostCheckActive: true })
105
+
106
+ const BUILTIN_VERIFIERS = { loopback: loopbackVerifier }
107
+
108
+ function resolveSecurityOptions (conf = {}, ctx = {}) {
109
+ // A function from ctx, or the name of a built-in ('loopback') from ctx or common.json.
110
+ let authVerifier = ctx.authVerifier ?? conf.authVerifier
111
+ if (typeof authVerifier === 'string') {
112
+ if (!Object.hasOwn(BUILTIN_VERIFIERS, authVerifier)) {
113
+ throw new Error(`ERR_GATEWAY_AUTH_VERIFIER_INVALID: unknown built-in verifier "${authVerifier}" (known: ${Object.keys(BUILTIN_VERIFIERS).join(', ')})`)
114
+ }
115
+ authVerifier = BUILTIN_VERIFIERS[authVerifier]
116
+ }
117
+ if (authVerifier != null && typeof authVerifier !== 'function') {
118
+ throw new Error('ERR_GATEWAY_AUTH_VERIFIER_INVALID: authVerifier must be a function or the name of a built-in verifier')
119
+ }
120
+
121
+ // worker.js CLI flags (--allowed-hosts a,b --body-limit 2097152
122
+ // --trust-proxy true) arrive as strings.
123
+ let allowedHosts = _pick('allowedHosts', conf, ctx)
124
+ if (typeof allowedHosts === 'string') allowedHosts = allowedHosts.split(',').map(h => h.trim()).filter(Boolean)
125
+ if (allowedHosts !== undefined && !Array.isArray(allowedHosts)) {
126
+ throw new Error('ERR_GATEWAY_ALLOWED_HOSTS_INVALID: allowedHosts must be an array of host names')
127
+ }
128
+
129
+ // buildHostAllowlist turns the Host check off for '*'.
130
+ const hostCheckActive = !(allowedHosts || []).includes('*')
131
+ if (authVerifier === loopbackVerifier && hostCheckActive) authVerifier = loopbackVerifierHostChecked
132
+
133
+ let bodyLimit = _pick('bodyLimit', conf, ctx) ?? DEFAULT_BODY_LIMIT
134
+ if (typeof bodyLimit === 'string' && /^\d+$/.test(bodyLimit)) bodyLimit = Number(bodyLimit)
135
+ if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) {
136
+ throw new Error('ERR_GATEWAY_BODY_LIMIT_INVALID: bodyLimit must be a positive integer (bytes)')
137
+ }
138
+
139
+ const rateLimit = _pick('rateLimit', conf, ctx)
140
+ const helmet = _pick('helmet', conf, ctx)
141
+
142
+ return {
143
+ authVerifier: authVerifier || null,
144
+ allowedHosts: allowedHosts || [],
145
+ hostCheckActive,
146
+ trustProxy: _trustProxy(_pick('trustProxy', conf, ctx)),
147
+ bodyLimit,
148
+ // false switches a layer off; an object is merged over the defaults.
149
+ rateLimit: rateLimit === false ? false : { ...DEFAULT_RATE_LIMIT, ...(rateLimit || {}) },
150
+ helmet: helmet === false ? false : { ...DEFAULT_HELMET, ...(helmet || {}) }
151
+ }
152
+ }
153
+
154
+ // Splits a Host header (or an allowlist entry) into a lower-cased hostname and
155
+ // an optional port, keeping IPv6 literals bracketed: "[::1]:3000" → ["[::1]", "3000"].
156
+ function _splitHost (value) {
157
+ const v = String(value).trim().toLowerCase()
158
+ const m = v.startsWith('[') ? v.match(/^(\[[^\]]*\])(?::(\d+))?$/) : v.match(/^([^:]+)(?::(\d+))?$/)
159
+ if (!m) return null
160
+ return { hostname: m[1], port: m[2] || null }
161
+ }
162
+
163
+ // The Host values this gateway answers to. Loopback on the bound port is always
164
+ // in, plus the bound address itself when it is a specific one, plus whatever the
165
+ // operator lists. An entry with a port matches only that port; one without
166
+ // matches the host on any port (a reverse proxy usually drops :443). '*' turns
167
+ // the check off.
168
+ function buildHostAllowlist (configured, { port, host } = {}) {
169
+ if (configured.includes('*')) return null
170
+
171
+ const entries = LOOPBACK_HOSTS.map(h => `${h}:${port}`)
172
+ if (host && !WILDCARD_BIND.has(host)) {
173
+ entries.push(`${host.includes(':') && !host.startsWith('[') ? `[${host}]` : host}:${port}`)
174
+ }
175
+ entries.push(...configured)
176
+
177
+ const list = []
178
+ for (const entry of entries) {
179
+ const parsed = _splitHost(entry)
180
+ if (!parsed) throw new Error(`ERR_GATEWAY_ALLOWED_HOSTS_INVALID: "${entry}"`)
181
+ list.push(parsed)
182
+ }
183
+ return list
184
+ }
185
+
186
+ function isHostAllowed (allowlist, hostHeader) {
187
+ if (allowlist === null) return true
188
+ if (!hostHeader) return false
189
+ const got = _splitHost(hostHeader)
190
+ if (!got) return false
191
+ return allowlist.some(e => e.hostname === got.hostname && (e.port === null || e.port === got.port))
192
+ }
193
+
194
+ // onRequest hook: a request whose Host is not this gateway's is refused before
195
+ // routing, which is what stops a DNS-rebound page from reading a localhost
196
+ // gateway. request.host honours X-Forwarded-Host only when trustProxy is on.
197
+ function createHostCheck (allowlist) {
198
+ return async function hostCheck (request) {
199
+ if (!isHostAllowed(allowlist, request.host)) {
200
+ throw Object.assign(new Error('ERR_GATEWAY_HOST_NOT_ALLOWED'), { statusCode: 403 })
201
+ }
202
+ }
203
+ }
204
+
205
+ function _unauthorized () {
206
+ return Object.assign(new Error('ERR_AUTH_UNAUTHORIZED'), { statusCode: 401 })
207
+ }
208
+
209
+ // Route-level preParsing hook for an `auth: true` route. With no verifier the route is
210
+ // closed (401); with one, its truthy return is the caller's identity and becomes
211
+ // req._info for the controller. A verifier may throw its own 4xx ERR_* (403 for
212
+ // a missing permission, say); anything else it throws fails closed as a 401.
213
+ function createAuthHook (authVerifier, routeInfo) {
214
+ return async function authHook (request) {
215
+ if (!authVerifier) throw _unauthorized()
216
+
217
+ let identity
218
+ try {
219
+ identity = await authVerifier(request, routeInfo)
220
+ } catch (err) {
221
+ const status = err?.statusCode
222
+ if (Number.isInteger(status) && status >= 400 && status < 500 && /^ERR_/.test(err.message)) throw err
223
+ request.log?.error(err)
224
+ throw _unauthorized()
225
+ }
226
+
227
+ if (!identity) throw _unauthorized()
228
+ request._info = typeof identity === 'object' ? identity : {}
229
+ request._authenticated = true
230
+ }
231
+ }
232
+
233
+ function canOverwriteCache (request) {
234
+ if (request._authenticated !== true) return false
235
+ const perms = request._info?.perms
236
+ return Array.isArray(perms) && perms.includes(OVERWRITE_CACHE_PERM)
237
+ }
238
+
239
+ module.exports = {
240
+ DEFAULT_BODY_LIMIT,
241
+ DEFAULT_RATE_LIMIT,
242
+ DEFAULT_HELMET,
243
+ OVERWRITE_CACHE_PERM,
244
+ loopbackVerifier,
245
+ createLoopbackVerifier,
246
+ resolveSecurityOptions,
247
+ buildHostAllowlist,
248
+ isHostAllowed,
249
+ createHostCheck,
250
+ createAuthHook,
251
+ canOverwriteCache
252
+ }
@@ -0,0 +1,48 @@
1
+ 'use strict'
2
+
3
+ function dequeueRequests (requests, key, data, err = null) {
4
+ requests.get(key).forEach(({ resolve, reject }) => {
5
+ if (err) reject(err)
6
+ else resolve(data)
7
+ })
8
+ requests.delete(key)
9
+ }
10
+
11
+ async function cachedRoute (ctx, ckeyParts, apiPath, func, overwriteCache = false) {
12
+ const lru = ctx[`lru_${ctx.conf.cacheTiming?.[apiPath] || '30s'}`]
13
+ if (!lru) throw new Error('INTERNAL_SERVER_ERROR')
14
+
15
+ // JSON keeps part boundaries: joining on ':' let ['a:b', 'c'] and
16
+ // ['a', 'b:c'] share one entry. undefined and null both encode as null.
17
+ const ckey = JSON.stringify(ckeyParts.map(k => k ?? null))
18
+
19
+ if (!overwriteCache) {
20
+ const cached = lru.get(ckey)
21
+ if (cached !== undefined) return cached
22
+ }
23
+
24
+ const requests = ctx.queuedRequests
25
+
26
+ if (requests.has(ckey)) {
27
+ return new Promise((resolve, reject) => {
28
+ requests.get(ckey).push({ resolve, reject })
29
+ })
30
+ }
31
+
32
+ requests.set(ckey, [])
33
+
34
+ let data
35
+ try {
36
+ data = await func()
37
+ } catch (err) {
38
+ dequeueRequests(requests, ckey, null, err)
39
+ throw err
40
+ }
41
+
42
+ lru.set(ckey, data)
43
+ dequeueRequests(requests, ckey, data)
44
+
45
+ return data
46
+ }
47
+
48
+ module.exports = { cachedRoute }
@@ -0,0 +1,9 @@
1
+ 'use strict'
2
+
3
+ function send200 (rep, data) {
4
+ rep.status(200).send(data)
5
+ }
6
+
7
+ module.exports = {
8
+ send200
9
+ }
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const { RPC_TIMEOUT } = require('./constants')
5
+
6
+ const getRpcTimeout = (conf) => {
7
+ return conf.rpcTimeout || RPC_TIMEOUT
8
+ }
9
+
10
+ // tether-wrk-base opens the worker's corestore at `store/<name>` relative to
11
+ // the process's working directory, unless env is 'test' and ctx.tmpdir is set.
12
+ // Anchor that default to the worker's own data dir instead (ctx.tmpdir when
13
+ // given, else ctx.root), so the store sits with the rest of the gateway's
14
+ // state whatever directory the process starts in. An absolute path, or the
15
+ // base's test-env tmpdir placement, is returned unchanged.
16
+ const resolveStoreDir = (storeDir, ctx = {}) => {
17
+ if (!storeDir || path.isAbsolute(storeDir)) return storeDir
18
+ if (ctx.env === 'test' && ctx.tmpdir) return storeDir
19
+ const base = ctx.tmpdir || ctx.root
20
+ return base ? path.resolve(base, storeDir) : storeDir
21
+ }
22
+
23
+ module.exports = {
24
+ getRpcTimeout,
25
+ resolveStoreDir
26
+ }