@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.
- package/LICENSE +202 -0
- package/README.md +306 -0
- package/config/common.json.example +15 -0
- package/config/facs/httpd.config.json.example +4 -0
- package/config/facs/net.config.json.example +5 -0
- package/config/facs/store.config.json.example +0 -0
- package/package.json +69 -1
- package/plugin.js +16 -0
- package/worker.js +5 -0
- package/workers/http.node.wrk.js +175 -0
- package/workers/lib/constants.js +7 -0
- package/workers/lib/logger.js +70 -0
- package/workers/lib/plugin-adapter.js +189 -0
- package/workers/lib/plugin-gateway.js +67 -0
- package/workers/lib/plugin-loader.js +6 -0
- package/workers/lib/security.js +252 -0
- package/workers/lib/server/lib/cachedRoute.js +48 -0
- package/workers/lib/server/lib/send200.js +9 -0
- package/workers/lib/utils.js +26 -0
|
@@ -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,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
|
+
}
|