Track online backup source IP and add detailed recovery output
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 05:02:21 +02:00
parent 560966fa2a
commit 43b77401c6
15 changed files with 147 additions and 26 deletions
+15 -5
View File
@@ -261,7 +261,7 @@ async function recordExists(rootDir, id) {
}
}
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs) {
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs, sourceIp) {
await mkdir(rootDir, { recursive: true })
await cleanupTemporaryFiles(rootDir)
await cleanupExpiredRecords(rootDir, nowMs)
@@ -271,7 +271,8 @@ async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs
: null
const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString()
const contents = Buffer.from(JSON.stringify({
version: payload.recovery ? 3 : 2,
version: 4,
sourceIp,
...(payload.recovery ? { recovery: payload.recovery } : {}),
blob: payload.blob,
deleteVerifier: payload.deleteVerifier,
@@ -342,7 +343,10 @@ async function readRecord(rootDir, id) {
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
&& isCanonicalTimestamp(record.createdAt)
const expiring = ((keys === 'blob,createdAt,deleteVerifier,expiresAt,version' && record.version === 2)
|| (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery)))
|| (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,version' && record.version === 3 && validRecovery(record.recovery))
|| (record.version === 4 && typeof record.sourceIp === 'string' && isIP(record.sourceIp)
&& (keys === 'blob,createdAt,deleteVerifier,expiresAt,sourceIp,version'
|| (keys === 'blob,createdAt,deleteVerifier,expiresAt,recovery,sourceIp,version' && validRecovery(record.recovery)))))
&& validBlob
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
&& isCanonicalTimestamp(record.createdAt)
@@ -620,9 +624,15 @@ export function createBackupServer(options) {
sendJson(response, 429, { error: 'rate_limited' })
return
}
let createdAtMs
const sourceIp = address.startsWith('::ffff:') && isIP(address.slice(7)) === 4 ? address.slice(7) : address
if (!isIP(sourceIp)) throw new Error('Invalid client address')
const result = await runStorageMutation(() => withStorageLock(
options.rootDir,
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, Number(now()))
() => {
createdAtMs = Number(now())
return createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, createdAtMs, sourceIp)
}
))
if (result === 'duplicate') {
sendJson(response, 409, { error: 'already_exists' })
@@ -632,7 +642,7 @@ export function createBackupServer(options) {
sendJson(response, 507, { error: 'insufficient_storage' })
return
}
sendJson(response, 201, { created: true })
sendJson(response, 201, { created: true, sourceIp, createdAt: new Date(createdAtMs).toISOString() })
return
} finally {
bodyConcurrency.leave(address)
+31 -1
View File
@@ -76,6 +76,7 @@ test('creates immutable ciphertext records and restores them after a restart', a
body: JSON.stringify(backup.payload)
})
assert.equal(created.status, 201)
assert.equal((await created.json()).sourceIp, '127.0.0.1')
await new Promise((resolve) => api.server.close(resolve))
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
@@ -157,7 +158,7 @@ test('expires finite backups at the exact deadline and removes their ciphertext'
assert.equal(created.status, 201)
const storedPath = join(api.rootDir, `${backup.payload.id}.json`)
const stored = JSON.parse(await readFile(storedPath, 'utf8'))
assert.equal(stored.version, 2)
assert.equal(stored.version, 4)
assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z')
const beforeDeadline = await request(api, '/v1/backups/restore', {
@@ -347,6 +348,35 @@ test('uses the last forwarded address from an explicitly trusted proxy', async (
assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429)
})
test('stores only server-derived source IP and keeps it out of public restore responses', async t => {
for (const [trustedProxy, forwarded, expected] of [
[false, '198.51.100.2', '127.0.0.1'],
[true, '198.51.100.2, 203.0.113.9', '203.0.113.9'],
[true, '2001:db8::42', '2001:db8::42'],
[true, 'invalid', '127.0.0.1']
]) {
const api = await startApi({ trustedProxy, trustedProxyAddresses: ['127.0.0.1'] })
t.after(() => api.close())
const backup = fixture()
const create = payload => request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(payload) })
assert.equal((await create({ ...backup.payload, sourceIp: '192.0.2.1' })).status, 400)
const created = await create(backup.payload)
assert.equal(created.status, 201)
const metadata = await created.json()
assert.equal(metadata.sourceIp, expected)
const file = join(api.rootDir, `${backup.payload.id}.json`)
const stored = JSON.parse(await readFile(file, 'utf8'))
assert.equal(stored.sourceIp, expected)
assert.equal(stored.createdAt, metadata.createdAt)
const restoreRecord = () => request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: backup.payload.id }) })
assert.deepEqual(await (await restoreRecord()).json(), { blob: backup.payload.blob })
delete stored.sourceIp
stored.version = 2
await writeFile(file, JSON.stringify(stored))
assert.equal((await restoreRecord()).status, 200)
}
})
test('keeps concurrency leases until storage mutations finish', async (t) => {
const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 })
t.after(() => api.close())