fix(backend): validate the S3 endpoint host before the restore download (stable) (#1391)
* fix(backend): validate the S3 endpoint host before the restore download
downloadFileFromS3() built an S3StorageAdapter and called .download()
directly, skipping the isHostAllowed() private-IP/DNS-rebinding guard
that testConnection() applies elsewhere — an admin with backup.restore
could point the configured S3 endpoint at an internal/metadata address
for unauthenticated egress via the server.
Backport of 89c6a673bf from main
(GHSA-vm2x-c628-3cx5) to stable; cherry-picked cleanly, no adaptation
needed.
* fix(backend): pin the restore S3 download to its validated DNS resolution
isHostAllowed() was check-then-connect: the AWS SDK re-resolves the
endpoint hostname independently when it actually connects, so a DNS
rebinding condition between the preflight check and the real
connection could still reach a private/internal address.
Stable didn't yet have the pinnedRequestOptions() primitive main's
webhookDeliveryWorker.js uses for this, so it's ported here as
utils/pinnedRequest.js, plus an additive
validateExternalUrlWithAddresses() in networkValidation.js that
returns the resolved address set (existing exports/behavior
untouched). Both get wired into the S3Client's requestHandler for the
restore download path only.
---------
Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
167755fdec
commit
c008f43a7e
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Blind SSRF via the restore S3 download path (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* downloadFileFromS3() built a bare S3StorageAdapter and called .download()
|
||||
* directly, never running the DNS-resolving isHostAllowed() guard that
|
||||
* testConnection() applies elsewhere — so an admin with backup.restore could
|
||||
* point the request-supplied S3 endpoint at an internal/metadata address for
|
||||
* unauthenticated egress via the server. `s3Config` here is fully attacker
|
||||
* controlled (POST /api/admin/restore/validate and /restore/start take it
|
||||
* straight from the request body — see routes/adminRestore.js), unlike the
|
||||
* scheduled-backup S3 endpoint, which is vetted at settings-save time.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restores3ssrf-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3ssrf-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
download: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const lookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 SSRF guard (GHSA-vm2x-c628-3cx5)', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to a private/internal address before any network call', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'evil-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to the cloud metadata address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'metadata-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a legitimate public S3 endpoint through to download()', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 's3.example-cdn.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not require the guard outside production (dev MinIO stays usable), but still downloads', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); // would be rejected in prod
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'localhost:9000', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user