474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
|
import { createServer } from 'node:http'
|
|
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
import { isIP } from 'node:net'
|
|
import { join } from 'node:path'
|
|
import lockfile from 'proper-lockfile'
|
|
import { recoveryDescriptor, validRecoveryEnvelope } from './recovery.mjs'
|
|
|
|
const maxBlobBytes = 256 * 1024
|
|
const maxBodyBytes = 384 * 1024
|
|
const idPattern = /^[A-Za-z0-9_-]{22}$/
|
|
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
|
const blobPattern = /^[A-Za-z0-9_-]+$/
|
|
const notFoundBody = '{"error":"not_found"}'
|
|
|
|
function isCanonicalBase64Url(value, byteLength, pattern) {
|
|
if (typeof value !== 'string' || !pattern.test(value)) return false
|
|
const decoded = Buffer.from(value, 'base64url')
|
|
return decoded.length === byteLength && decoded.toString('base64url') === value
|
|
}
|
|
|
|
function isValidBackup(payload, recovery) {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
|
const keys = Object.keys(payload).sort()
|
|
if (!['blob,deleteVerifier,id', 'blob,deleteVerifier,id,recovery'].includes(keys.join(','))) return false
|
|
if ('recovery' in payload && !validRecoveryEnvelope(payload.recovery, recovery)) return false
|
|
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
|
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
|
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
|
const decoded = Buffer.from(payload.blob, 'base64url')
|
|
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
|
|
}
|
|
|
|
function createRateLimiter({ max, windowMs }) {
|
|
const clients = new Map()
|
|
let requestCount = 0
|
|
return address => {
|
|
const now = Date.now()
|
|
requestCount += 1
|
|
if (requestCount % 1024 === 0) {
|
|
for (const [key, value] of clients) {
|
|
if (now - value.startedAt >= windowMs) clients.delete(key)
|
|
}
|
|
}
|
|
const current = clients.get(address)
|
|
if (!current || now - current.startedAt >= windowMs) {
|
|
clients.set(address, { startedAt: now, count: 1 })
|
|
return null
|
|
}
|
|
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
|
|
current.count += 1
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readJsonBody(request) {
|
|
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
|
|
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
|
request.resume()
|
|
return Promise.resolve({ error: 413 })
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
let size = 0
|
|
let settled = false
|
|
const chunks = []
|
|
const cleanup = () => {
|
|
request.off('data', onData)
|
|
request.off('end', onEnd)
|
|
request.off('aborted', onAborted)
|
|
request.off('error', onError)
|
|
}
|
|
const finish = result => {
|
|
if (settled) return
|
|
settled = true
|
|
cleanup()
|
|
resolve(result)
|
|
}
|
|
const onData = chunk => {
|
|
size += chunk.length
|
|
if (size > maxBodyBytes) {
|
|
finish({ error: 413 })
|
|
request.resume()
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
}
|
|
const onEnd = () => {
|
|
try {
|
|
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
|
} catch {
|
|
finish({ error: 400 })
|
|
}
|
|
}
|
|
const onAborted = () => reject(new Error('Request aborted'))
|
|
const onError = error => reject(error)
|
|
request.on('data', onData)
|
|
request.on('end', onEnd)
|
|
request.on('aborted', onAborted)
|
|
request.on('error', onError)
|
|
})
|
|
}
|
|
|
|
function recordPath(rootDir, id) {
|
|
return join(rootDir, `${id}.json`)
|
|
}
|
|
|
|
function createMutationQueue() {
|
|
let pending = Promise.resolve()
|
|
return operation => {
|
|
const result = pending.then(operation, operation)
|
|
pending = result.catch(() => {})
|
|
return result
|
|
}
|
|
}
|
|
|
|
async function directoryUsage(rootDir) {
|
|
let total = 0
|
|
const entries = await readdir(rootDir, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
try {
|
|
total += (await stat(join(rootDir, entry.name))).size
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
async function syncDirectory(rootDir) {
|
|
let handle
|
|
try {
|
|
handle = await open(rootDir, 'r')
|
|
await handle.sync()
|
|
} catch (error) {
|
|
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
|
|
} finally {
|
|
await handle?.close().catch(() => {})
|
|
}
|
|
}
|
|
|
|
async function withStorageLock(rootDir, operation) {
|
|
await mkdir(rootDir, { recursive: true })
|
|
const release = await lockfile.lock(rootDir, {
|
|
realpath: false,
|
|
lockfilePath: join(rootDir, '.storage.lock'),
|
|
stale: 30_000,
|
|
update: 10_000,
|
|
retries: {
|
|
retries: 100,
|
|
factor: 1.1,
|
|
minTimeout: 10,
|
|
maxTimeout: 100,
|
|
randomize: true
|
|
}
|
|
})
|
|
try {
|
|
return await operation()
|
|
} finally {
|
|
let releaseError
|
|
try {
|
|
await release()
|
|
} catch (error) {
|
|
releaseError = error
|
|
}
|
|
try {
|
|
await syncDirectory(rootDir)
|
|
} catch (error) {
|
|
releaseError ??= error
|
|
}
|
|
if (releaseError) throw releaseError
|
|
}
|
|
}
|
|
|
|
async function recordExists(rootDir, id) {
|
|
try {
|
|
await stat(recordPath(rootDir, id))
|
|
return true
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function createRecord(rootDir, payload, maxStorageBytes) {
|
|
await mkdir(rootDir, { recursive: true })
|
|
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
|
const contents = Buffer.from(JSON.stringify({
|
|
version: 1,
|
|
blob: payload.blob,
|
|
deleteVerifier: payload.deleteVerifier,
|
|
...(payload.recovery ? { recovery: payload.recovery } : {}),
|
|
createdAt: new Date().toISOString()
|
|
}), 'utf8')
|
|
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
|
|
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
|
let handle
|
|
let temporaryCreated = false
|
|
let published = false
|
|
try {
|
|
handle = await open(temporaryPath, 'wx', 0o600)
|
|
temporaryCreated = true
|
|
try {
|
|
await handle.writeFile(contents)
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
handle = undefined
|
|
}
|
|
try {
|
|
await link(temporaryPath, recordPath(rootDir, payload.id))
|
|
} catch (error) {
|
|
if (error.code === 'EEXIST') return 'duplicate'
|
|
throw error
|
|
}
|
|
published = true
|
|
return 'created'
|
|
} finally {
|
|
let cleanupError
|
|
try {
|
|
await handle?.close()
|
|
} catch (error) {
|
|
cleanupError = error
|
|
}
|
|
if (temporaryCreated) {
|
|
try {
|
|
await unlink(temporaryPath)
|
|
} catch (error) {
|
|
cleanupError ??= error
|
|
}
|
|
}
|
|
if (published) {
|
|
try {
|
|
await syncDirectory(rootDir)
|
|
} catch (error) {
|
|
cleanupError ??= error
|
|
}
|
|
}
|
|
if (cleanupError) throw cleanupError
|
|
}
|
|
}
|
|
|
|
async function readRecord(rootDir, id) {
|
|
try {
|
|
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
|
const record = JSON.parse(raw)
|
|
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
|
throw new Error('Invalid stored record')
|
|
}
|
|
return record
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function securityHeaders(response) {
|
|
response.setHeader('cache-control', 'no-store')
|
|
response.setHeader('x-content-type-options', 'nosniff')
|
|
response.setHeader('content-security-policy', "default-src 'none'")
|
|
response.setHeader('referrer-policy', 'no-referrer')
|
|
}
|
|
|
|
function sendJson(response, status, body) {
|
|
response.statusCode = status
|
|
response.setHeader('content-type', 'application/json; charset=utf-8')
|
|
response.end(JSON.stringify(body))
|
|
}
|
|
|
|
function sendNotFound(response) {
|
|
response.statusCode = 404
|
|
response.setHeader('content-type', 'application/json; charset=utf-8')
|
|
response.end(notFoundBody)
|
|
}
|
|
|
|
function authorizeOrigin(request, response, allowedOrigins) {
|
|
const origin = request.headers.origin
|
|
if (!origin) return true
|
|
if (!allowedOrigins.has(origin)) {
|
|
sendJson(response, 403, { error: 'origin_denied' })
|
|
return false
|
|
}
|
|
response.setHeader('access-control-allow-origin', origin)
|
|
response.setHeader('vary', 'Origin')
|
|
return true
|
|
}
|
|
|
|
function verifierMatches(secret, expectedVerifier) {
|
|
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
|
|
const expected = Buffer.from(expectedVerifier, 'base64url')
|
|
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
}
|
|
|
|
async function storageIsReady(rootDir) {
|
|
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
|
|
try {
|
|
await mkdir(rootDir, { recursive: true })
|
|
const handle = await open(probePath, 'wx', 0o600)
|
|
await handle.close()
|
|
await unlink(probePath)
|
|
return true
|
|
} catch {
|
|
await unlink(probePath).catch(() => {})
|
|
return false
|
|
}
|
|
}
|
|
|
|
function clientAddress(request, trustedProxy) {
|
|
if (trustedProxy) {
|
|
const forwarded = request.headers['x-forwarded-for']
|
|
const candidate = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',', 1)[0].trim()
|
|
if (candidate && isIP(candidate)) return candidate
|
|
}
|
|
return request.socket.remoteAddress ?? 'unknown'
|
|
}
|
|
|
|
export function createBackupServer(options) {
|
|
if (!options?.rootDir) throw new Error('rootDir is required')
|
|
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
|
const recovery = options.recoveryPublicKey ? recoveryDescriptor(options.recoveryPublicKey) : null
|
|
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
|
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
|
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
|
|
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
|
throw new Error('Invalid rate limit')
|
|
}
|
|
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
|
|
throw new Error('Invalid upload rate limit')
|
|
}
|
|
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
|
|
const consumeRateLimit = createRateLimiter(rateLimit)
|
|
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
|
const runStorageMutation = createMutationQueue()
|
|
|
|
return createServer(async (request, response) => {
|
|
securityHeaders(response)
|
|
try {
|
|
const url = new URL(request.url, 'http://localhost')
|
|
if (!authorizeOrigin(request, response, allowedOrigins)) return
|
|
if (url.search) {
|
|
sendNotFound(response)
|
|
return
|
|
}
|
|
|
|
if (request.method === 'OPTIONS') {
|
|
const requestedMethod = request.headers['access-control-request-method']
|
|
if (!request.headers.origin || requestedMethod !== 'POST') {
|
|
sendJson(response, 400, { error: 'invalid_preflight' })
|
|
return
|
|
}
|
|
response.statusCode = 204
|
|
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
|
|
response.setHeader('access-control-allow-headers', 'content-type')
|
|
response.setHeader('access-control-max-age', '600')
|
|
response.end()
|
|
return
|
|
}
|
|
|
|
if (request.method === 'GET' && url.pathname === '/health') {
|
|
const ready = await storageIsReady(options.rootDir)
|
|
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
|
const retryAfter = consumeRateLimit(clientAddress(request, options.trustedProxy === true))
|
|
if (retryAfter !== null) {
|
|
response.setHeader('retry-after', String(retryAfter))
|
|
sendJson(response, 429, { error: 'rate_limited' })
|
|
return
|
|
}
|
|
}
|
|
|
|
if (request.method === 'POST' && url.pathname === '/v1/backups/recovery-key') {
|
|
request.resume()
|
|
sendJson(response, recovery ? 200 : 503, recovery ?? { error: 'recovery_unavailable' })
|
|
return
|
|
}
|
|
|
|
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
|
|
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
|
sendJson(response, 415, { error: 'unsupported_media_type' })
|
|
return
|
|
}
|
|
const parsed = await readJsonBody(request)
|
|
if (parsed.error) {
|
|
if (parsed.error === 413) response.setHeader('connection', 'close')
|
|
sendJson(response, parsed.error, { error: parsed.error === 413 ? 'payload_too_large' : 'invalid_request' })
|
|
return
|
|
}
|
|
if (url.pathname === '/v1/backups/restore') {
|
|
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
|
? Object.keys(parsed.value)
|
|
: []
|
|
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
|
|
sendNotFound(response)
|
|
return
|
|
}
|
|
const record = await readRecord(options.rootDir, parsed.value.id)
|
|
if (!record) {
|
|
sendNotFound(response)
|
|
return
|
|
}
|
|
sendJson(response, 200, { blob: record.blob })
|
|
return
|
|
}
|
|
if (url.pathname === '/v1/backups/delete') {
|
|
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
|
? Object.keys(parsed.value).sort()
|
|
: []
|
|
const valid = keys.join(',') === 'deleteSecret,id'
|
|
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
|
|
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
|
|
if (!valid) {
|
|
sendNotFound(response)
|
|
return
|
|
}
|
|
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
|
const record = await readRecord(options.rootDir, parsed.value.id)
|
|
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
|
|
try {
|
|
await unlink(recordPath(options.rootDir, parsed.value.id))
|
|
return true
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return false
|
|
throw error
|
|
}
|
|
}))
|
|
if (!deleted) {
|
|
sendNotFound(response)
|
|
return
|
|
}
|
|
response.statusCode = 204
|
|
response.end()
|
|
return
|
|
}
|
|
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
|
|
sendJson(response, 413, { error: 'payload_too_large' })
|
|
return
|
|
}
|
|
if (!isValidBackup(parsed.value, recovery)) {
|
|
sendJson(response, 400, { error: 'invalid_request' })
|
|
return
|
|
}
|
|
const uploadRetryAfter = consumeUploadRateLimit(clientAddress(request, options.trustedProxy === true))
|
|
if (uploadRetryAfter !== null) {
|
|
response.setHeader('retry-after', String(uploadRetryAfter))
|
|
sendJson(response, 429, { error: 'rate_limited' })
|
|
return
|
|
}
|
|
const result = await runStorageMutation(() => withStorageLock(
|
|
options.rootDir,
|
|
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
|
|
))
|
|
if (result === 'duplicate') {
|
|
sendJson(response, 409, { error: 'already_exists' })
|
|
return
|
|
}
|
|
if (result === 'full') {
|
|
sendJson(response, 507, { error: 'insufficient_storage' })
|
|
return
|
|
}
|
|
sendJson(response, 201, { created: true })
|
|
return
|
|
}
|
|
|
|
sendNotFound(response)
|
|
} catch {
|
|
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
|
|
else response.destroy()
|
|
}
|
|
})
|
|
}
|