[verified] refactor: harden operations and redesign device console
This commit is contained in:
+37
-2
@@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import { redactInstance } from './core.js'
|
||||
import { registerStatusRoutes } from './status/routes.js'
|
||||
import { registerProxyRoutes } from './proxy/routes.js'
|
||||
import { defaultProxyPolicy } from './proxy/policy.js'
|
||||
import { ConfirmationStore } from './proxy/confirmations.js'
|
||||
import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const FEATURES = ['passwordless-status', 'password-session-login', 'read-write-api-proxy', 'device-sim-network-sms-data-ota-summary', 'iframe-fallback']
|
||||
@@ -19,9 +22,41 @@ const CATALOG = [
|
||||
{ id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] },
|
||||
]
|
||||
|
||||
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100 } = {}) {
|
||||
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
|
||||
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
|
||||
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
|
||||
const parseAuthority = raw => {
|
||||
const value = String(raw || '').trim().toLowerCase()
|
||||
if (!value || /[\s/@]/.test(value)) return null
|
||||
let hostname, port = ''
|
||||
if (value.startsWith('[')) { const match=value.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
|
||||
else { const match=value.match(/^([^:]+)(?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
|
||||
if(port && Number(port)>65535)return null
|
||||
return { hostname, port }
|
||||
}
|
||||
const loopbackAuthority = raw => {
|
||||
const authority=parseAuthority(raw); if(!authority)return null
|
||||
const {hostname}=authority
|
||||
if(hostname === 'localhost') return { ...authority, hostname: 'localhost' }
|
||||
if(isIP(hostname) === 6 && (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1')) return { ...authority, hostname: '::1' }
|
||||
if(isIP(hostname)!==4)return null
|
||||
const octets=hostname.split('.').map(Number)
|
||||
return octets[0]===127 ? authority : null
|
||||
}
|
||||
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
|
||||
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
|
||||
const configuredPort = String(configStore.snapshot.server.port)
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const authority=loopbackAuthority(request.headers.host)
|
||||
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
|
||||
if (!authority || (!injectDefault && (authority.hostname !== configuredHost || authority.port !== configuredPort))) return reply.code(421).send({ error: 'configured loopback Host required' })
|
||||
if (request.headers.origin) {
|
||||
let origin
|
||||
try { origin = new URL(request.headers.origin) } catch {}
|
||||
const originAuthority = origin ? loopbackAuthority(origin.host) : null
|
||||
if (!origin || origin.protocol !== 'http:' || !originAuthority || originAuthority.hostname !== authority.hostname || originAuthority.port !== authority.port) return reply.code(403).send({ error: 'cross-origin request rejected' })
|
||||
}
|
||||
})
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
|
||||
@@ -73,7 +108,7 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
|
||||
client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true }
|
||||
})
|
||||
registerProxyRoutes(app, { findClient })
|
||||
registerProxyRoutes(app, { findClient, policy: proxyPolicy, configStore, confirmationStore: new ConfirmationStore(confirmationOptions) })
|
||||
app.get('/api/catalog', async () => ({ groups: CATALOG }))
|
||||
app.get('/api/reload-note', async () => ({ message: 'Configuration changes are applied immediately.' }))
|
||||
app.setNotFoundHandler(async (request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ error: 'not found' }) : staticFiles ? reply.sendFile('index.html') : reply.code(404).send({ error: 'not found' }))
|
||||
|
||||
@@ -32,7 +32,7 @@ export class FileConfigStore {
|
||||
this.examplePath = examplePath
|
||||
this.atomicWrite = atomicWrite
|
||||
this.env = { ...env }
|
||||
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
|
||||
this.#snapshot = immutableSnapshot({ ...snapshot, revision: Number(snapshot.revision || 0), configPath })
|
||||
}
|
||||
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
|
||||
let text
|
||||
@@ -52,7 +52,7 @@ export class FileConfigStore {
|
||||
const result = await mutator(draft)
|
||||
const normalized = normalizeConfig(serializeConfig(draft), this.env)
|
||||
await this.atomicWrite(this.configPath, serializeConfig(normalized))
|
||||
this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath })
|
||||
this.#snapshot = immutableSnapshot({ ...normalized, revision: this.#snapshot.revision + 1, configPath: this.configPath })
|
||||
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
|
||||
})
|
||||
this.#queue = operation.catch(() => {})
|
||||
@@ -71,8 +71,16 @@ export class FileConfigStore {
|
||||
const index = draft.instances.findIndex(item => item.id === id)
|
||||
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
|
||||
const current = draft.instances[index]
|
||||
const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id,
|
||||
auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth }
|
||||
const action = payload.passwordAction
|
||||
if (action !== undefined && !['preserve', 'set', 'clear'].includes(action)) throw new ConfigValidationError('passwordAction must be preserve, set or clear')
|
||||
if (payload.auth && Object.hasOwn(payload.auth, 'password')) throw new ConfigValidationError("auth.password is ambiguous; use passwordAction='set' or 'clear'")
|
||||
if (Object.hasOwn(payload, 'password') && action === undefined) throw new ConfigValidationError("password requires passwordAction='set'")
|
||||
if (action === 'set' && !String(payload.password || '')) throw new ConfigValidationError("passwordAction='set' requires a non-empty password")
|
||||
const auth = action === 'set' ? { mode: 'password', password: String(payload.password) }
|
||||
: action === 'clear' ? { mode: 'none', password: '' }
|
||||
: current.auth
|
||||
const { passwordAction: _passwordAction, password: _password, auth: _payloadAuth, ...metadata } = payload
|
||||
const merged = { ...current, ...metadata, id: payload.id ? String(payload.id).trim() : current.id, auth }
|
||||
const next = normalizeInstance(merged, index)
|
||||
if (next.id !== id && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
|
||||
draft.instances[index] = next
|
||||
|
||||
+24
-1
@@ -1,10 +1,26 @@
|
||||
import { ConfigValidationError } from './errors.js'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export function normalizeBaseUrl(rawUrl) {
|
||||
let url
|
||||
try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https')
|
||||
if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials')
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
const ipv4 = hostname.split('.').map(Number)
|
||||
const isIpv4 = ipv4.length === 4 && ipv4.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
|
||||
const prohibitedIpv4 = isIpv4 && (
|
||||
ipv4[0] === 127 || ipv4[0] === 0 ||
|
||||
(ipv4[0] === 169 && ipv4[1] === 254) ||
|
||||
(ipv4[0] >= 224)
|
||||
)
|
||||
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
|
||||
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
|
||||
const prohibitedTransition = hostname.startsWith('2002:')
|
||||
const isIpLiteral = isIP(hostname) !== 0
|
||||
if (!isIpLiteral || prohibitedIpv4 || prohibitedIpv6 || prohibitedMetadata || prohibitedTransition) {
|
||||
throw new ConfigValidationError('instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited')
|
||||
}
|
||||
url.hash = ''
|
||||
url.search = ''
|
||||
return url.toString().replace(/\/$/, '')
|
||||
@@ -38,8 +54,15 @@ export function normalizeConfig(raw = {}, env = process.env) {
|
||||
}
|
||||
const port = Number(env.PORT ?? raw.server?.port ?? 8788)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535')
|
||||
const host = String(env.HOST ?? raw.server?.host ?? '127.0.0.1').trim()
|
||||
const bareHost = host.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
const octets = bareHost.split('.')
|
||||
const ipv4Loopback = octets.length === 4 && octets[0] === '127' && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
if (!(bareHost === 'localhost' || bareHost === '::1' || bareHost === '0:0:0:0:0:0:0:1' || ipv4Loopback)) {
|
||||
throw new ConfigValidationError('server.host must be a loopback host (127/8, ::1, or localhost)')
|
||||
}
|
||||
return {
|
||||
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
|
||||
server: { host, port },
|
||||
instances,
|
||||
raw: { ...raw, instances },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
export function canonicalQuery(input = '') {
|
||||
const params = new URLSearchParams(String(input).replace(/^\?/, ''))
|
||||
params.sort()
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function canonicalProxyTarget(input) {
|
||||
if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) throw new Error('invalid confirmation path')
|
||||
let url
|
||||
try { url = new URL(input, 'http://confirmation.local') } catch { throw new Error('invalid confirmation path') }
|
||||
if (url.origin !== 'http://confirmation.local' || url.hash) throw new Error('invalid confirmation path')
|
||||
return `${url.pathname}${canonicalQuery(url.search) ? `?${canonicalQuery(url.search)}` : ''}`
|
||||
}
|
||||
|
||||
export function bodyDigest(body) {
|
||||
const stable = value => {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (Buffer.isBuffer(value)) return value
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
|
||||
}
|
||||
const value = stable(body)
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export class ConfirmationStore {
|
||||
#entries = new Map()
|
||||
constructor({ ttlMs = 30_000, maxEntries = 1024, now = Date.now } = {}) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.now = now }
|
||||
prepare(binding) {
|
||||
const now = this.now()
|
||||
for (const [key, entry] of this.#entries) if (entry.expiresAt <= now) this.#entries.delete(key)
|
||||
while (this.#entries.size >= this.maxEntries) this.#entries.delete(this.#entries.keys().next().value)
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const expiresAt = now + this.ttlMs
|
||||
this.#entries.set(token, { ...binding, expiresAt })
|
||||
return { token, expiresAt }
|
||||
}
|
||||
consume(token, binding) {
|
||||
if (typeof token !== 'string' || token.length < 16) return false
|
||||
const entry = this.#entries.get(token)
|
||||
if (!entry) return false
|
||||
this.#entries.delete(token)
|
||||
if (entry.expiresAt <= this.now()) return false
|
||||
const expected = Buffer.from(JSON.stringify(binding))
|
||||
const actual = Buffer.from(JSON.stringify(Object.fromEntries(Object.keys(binding).map(key => [key, entry[key]]))))
|
||||
const matches = expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmationBinding({ instance, revision, method, target, body }) {
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
revision,
|
||||
origin: new URL(instance.url).origin,
|
||||
method: String(method).toUpperCase(),
|
||||
target: canonicalProxyTarget(target),
|
||||
bodyDigest: bodyDigest(body),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
|
||||
|
||||
export const DEFAULT_READ_PATHS = Object.freeze([
|
||||
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity',
|
||||
'/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation',
|
||||
'/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells',
|
||||
'/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles',
|
||||
'/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
|
||||
'/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles',
|
||||
'/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status',
|
||||
])
|
||||
|
||||
// Write access is deliberately narrower than the readable catalog. Every listed
|
||||
// operation still requires a one-use server confirmation.
|
||||
export const DEFAULT_WRITE_PATHS = Object.freeze({
|
||||
'/api/sms/send': ['POST'],
|
||||
'/api/data': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/roaming': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/airplane-mode': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/radio-mode': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/band-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'/api/cell-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'/api/apn': ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'/api/device-network/ddns/config': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/device-network/wlan/profiles': ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'/api/work-mode': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/esim/config': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/esim/profiles': ['POST', 'DELETE'],
|
||||
'/api/notifications/config': ['POST', 'PUT', 'PATCH'],
|
||||
'/api/automation/config': ['POST', 'PUT', 'PATCH'],
|
||||
})
|
||||
|
||||
export function createProxyPolicy({ readPaths = DEFAULT_READ_PATHS, writePaths = DEFAULT_WRITE_PATHS } = {}) {
|
||||
const readable = new Set(readPaths)
|
||||
const writable = new Map(Object.entries(writePaths).map(([path, methods]) => [path, new Set(methods.map(method => method.toUpperCase()))]))
|
||||
return Object.freeze({
|
||||
authorize(method, path) {
|
||||
method = String(method).toUpperCase()
|
||||
if (AUTH_PATH.test(path)) return { allowed: false, statusCode: 403, reason: 'authentication endpoints cannot be proxied' }
|
||||
if (method === 'GET' || method === 'HEAD') return readable.has(path)
|
||||
? { allowed: true, dangerous: false }
|
||||
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
|
||||
if (!writable.has(path)) return readable.has(path)
|
||||
? { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
|
||||
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
|
||||
return writable.get(path).has(method)
|
||||
? { allowed: true, dangerous: true }
|
||||
: { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const defaultProxyPolicy = createProxyPolicy()
|
||||
+50
-7
@@ -1,14 +1,57 @@
|
||||
import { proxyToInstance } from './service.js'
|
||||
import { proxyToInstance, safeProxyPath, ProxyRequestError } from './service.js'
|
||||
import { defaultProxyPolicy } from './policy.js'
|
||||
import { ConfirmationStore, confirmationBinding } from './confirmations.js'
|
||||
|
||||
function requestTarget(request, rest) {
|
||||
const path = safeProxyPath(rest)
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
||||
return `${path}${query}`
|
||||
}
|
||||
|
||||
export function registerProxyRoutes(app, {
|
||||
findClient,
|
||||
proxy = proxyToInstance,
|
||||
policy = defaultProxyPolicy,
|
||||
configStore,
|
||||
confirmationStore = new ConfirmationStore(),
|
||||
}) {
|
||||
app.post('/api/proxy-confirmations/prepare', async (request, reply) => {
|
||||
try {
|
||||
const input = request.body || {}
|
||||
const client = findClient(input.instanceId, reply)
|
||||
if (!client) return
|
||||
const method = String(input.method || '').toUpperCase()
|
||||
const target = String(input.path || '')
|
||||
const parsed = new URL(target, 'http://confirmation.local')
|
||||
if (parsed.origin !== 'http://confirmation.local') throw new Error('invalid confirmation path')
|
||||
const decision = policy.authorize(method, parsed.pathname)
|
||||
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
|
||||
if (!decision.dangerous) return reply.code(400).send({ error: 'confirmation is only available for dangerous writes' })
|
||||
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method, target, body: input.body })
|
||||
return confirmationStore.prepare(binding)
|
||||
} catch (error) { return reply.code(400).send({ error: error.message }) }
|
||||
})
|
||||
|
||||
export function registerProxyRoutes(app, { findClient, proxy = proxyToInstance }) {
|
||||
app.all('/api/proxy/:id/*', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply)
|
||||
if (!client) return
|
||||
try { return await proxy({ client, request, reply, rest: request.params['*'] }) }
|
||||
catch (error) {
|
||||
if (/invalid proxy path/.test(error.message)) return reply.code(400).send({ error: error.message })
|
||||
if (error.statusCode === 415) return reply.code(415).send({ error: error.message })
|
||||
throw error
|
||||
try {
|
||||
const target = requestTarget(request, request.params['*'])
|
||||
const pathname = new URL(target, 'http://proxy.local').pathname
|
||||
const decision = policy.authorize(request.method, pathname)
|
||||
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
|
||||
if (decision.dangerous) {
|
||||
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method: request.method, target, body: request.body })
|
||||
const token = request.headers['x-confirmation-token']
|
||||
if (!token) return reply.code(428).send({ error: 'confirmation token required' })
|
||||
if (!confirmationStore.consume(token, binding)) return reply.code(403).send({ error: 'invalid, expired, replayed, or mismatched confirmation token' })
|
||||
}
|
||||
return await proxy({ client, request, reply, rest: request.params['*'] })
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyRequestError && error.code === 'invalid_path') return reply.code(400).send({ error: 'invalid proxy path' })
|
||||
if (error instanceof ProxyRequestError && error.code === 'unsupported_content_type') return reply.code(415).send({ error: 'unsupported proxy content type' })
|
||||
request.log?.error?.({ err: error }, 'upstream proxy failed')
|
||||
return reply.code(502).send({ error: 'upstream request failed' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+17
-8
@@ -5,23 +5,32 @@ const BLOCKED_REQUEST_HEADERS = new Set([
|
||||
])
|
||||
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
|
||||
|
||||
export class ProxyRequestError extends Error {
|
||||
constructor(code) {
|
||||
super(code === 'unsupported_content_type' ? 'unsupported proxy content type' : 'invalid proxy path')
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const invalidPath = () => new ProxyRequestError('invalid_path')
|
||||
|
||||
export function safeProxyPath(rest = '') {
|
||||
const raw = String(rest || '')
|
||||
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path')
|
||||
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw invalidPath()
|
||||
let value = raw
|
||||
const assertSafe = candidate => {
|
||||
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('invalid proxy path')
|
||||
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw new Error('invalid proxy path')
|
||||
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw invalidPath()
|
||||
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw invalidPath()
|
||||
}
|
||||
for (let depth = 0; depth < 16; depth += 1) {
|
||||
assertSafe(value)
|
||||
let decoded
|
||||
try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') }
|
||||
try { decoded = decodeURIComponent(value) } catch { throw invalidPath() }
|
||||
if (decoded === value) return `/${raw}`
|
||||
value = decoded
|
||||
}
|
||||
// Refuse inputs whose semantics still change after a bounded number of decodes.
|
||||
throw new Error('invalid proxy path')
|
||||
throw invalidPath()
|
||||
}
|
||||
|
||||
export function proxyHeaders(input = {}) {
|
||||
@@ -37,17 +46,17 @@ export async function proxyToInstance({ client, request, reply, rest }) {
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
||||
const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin)
|
||||
const origin = new URL(client.instance.url).origin
|
||||
if (target.origin !== origin) throw new Error('invalid proxy path')
|
||||
if (target.origin !== origin) throw invalidPath()
|
||||
const headers = proxyHeaders(request.headers)
|
||||
let body
|
||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||
const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
||||
if (request.body === undefined && !type) body = undefined
|
||||
else if (type.startsWith('multipart/')) { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
|
||||
else if (type.startsWith('multipart/')) throw new ProxyRequestError('unsupported_content_type')
|
||||
else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body)
|
||||
else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body
|
||||
else if (Buffer.isBuffer(request.body)) body = request.body
|
||||
else { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
|
||||
else throw new ProxyRequestError('unsupported_content_type')
|
||||
}
|
||||
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
|
||||
reply.code(upstream.status)
|
||||
|
||||
Reference in New Issue
Block a user