[verified] refactor: harden operations and redesign device console

This commit is contained in:
chick
2026-07-15 16:33:16 +08:00
parent e04a16e817
commit 4977afdc20
24 changed files with 769 additions and 779 deletions
+63
View File
@@ -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),
}
}
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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)