// Private, bounded assets. The provider download uses a pinned lookup, not a URL precheck alone. const fs = require('fs'); const https = require('https'); const dns = require('dns').promises; const crypto = require('crypto'); const { isPrivateIp } = require('./urlSafety'); const MAX_BYTES = 16 * 1024 * 1024; function inspect(bytes, declared) { if (!Buffer.isBuffer(bytes) || !bytes.length || bytes.length > MAX_BYTES) throw new Error('Invalid image size'); const mime = bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) ? 'image/png' : bytes.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex')) ? 'image/jpeg' : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : null; if (!mime || (declared && declared.split(';')[0].trim().toLowerCase() !== mime)) throw new Error('Unsupported image content'); return { bytes, mime, checksum: crypto.createHash('sha256').update(bytes).digest('hex') }; } function decodeBase64(value) { if (typeof value !== 'string' || value.length > Math.ceil(MAX_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid image base64'); const bytes = Buffer.from(value, 'base64'); if (bytes.toString('base64') !== value) throw new Error('Noncanonical image base64'); return inspect(bytes); } async function download(url, { lookup = dns.lookup, request = https.get, signal } = {}) { const u = new URL(url); if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443')) throw new Error('Unsafe image URL'); const hostname = u.hostname.replace(/^\[|\]$/g, ''); const addresses = await lookup(hostname, { all: true }); if (!addresses.length || addresses.some(a => isPrivateIp(a.address))) throw new Error('Unsafe image address'); const pinned = addresses[0]; return new Promise((resolve, reject) => { // No proxy, redirects or authorization. TLS still verifies the original hostname. const req = request(u, { agent: false, signal, timeout: 30000, lookup: (_host, options, cb) => { cb(null, options.all ? [pinned] : pinned.address, pinned.family); }, headers: { Accept: 'image/png, image/jpeg, image/webp' } }, res => { if (res.statusCode !== 200 || !res.headers['content-type'] || Number(res.headers['content-length']) > MAX_BYTES) { res.destroy(); reject(new Error('Image download rejected')); return; } let size = 0; const chunks = []; res.on('data', chunk => { size += chunk.length; if (size > MAX_BYTES) { res.destroy(new Error('Image exceeds byte limit')); return; } chunks.push(chunk); }); res.on('error', reject); res.on('end', () => { try { resolve(inspect(Buffer.concat(chunks), res.headers['content-type'])); } catch (e) { reject(e); } }); }); req.on('timeout', () => req.destroy(new Error('Image download timeout'))); req.on('error', reject); }); } // Derived previews are addressed by asset id and width, so a request can only // ever reach a preview of the asset it already has permission to read. function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; } function createStorage(env = process.env) { const { S3Client, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); for (const key of ['ENDPOINT', 'ACCESS_KEY_FILE', 'SECRET_KEY_FILE']) if (!env['GENERATED_IMAGES_S3_' + key]) throw new Error('Generated image storage is not configured'); const client = new S3Client({ endpoint: env.GENERATED_IMAGES_S3_ENDPOINT, region: env.GENERATED_IMAGES_S3_REGION || 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: fs.readFileSync(env.GENERATED_IMAGES_S3_ACCESS_KEY_FILE, 'utf8').trim(), secretAccessKey: fs.readFileSync(env.GENERATED_IMAGES_S3_SECRET_KEY_FILE, 'utf8').trim() }, maxAttempts: 2, requestHandler: { connectionTimeout: 3000, requestTimeout: 15000 } }); const Bucket = env.GENERATED_IMAGES_S3_BUCKET || 'generated-images'; return { async ready() { await client.send(new HeadBucketCommand({ Bucket })); // Fail closed for a read-only credential too, before any image-provider call. const Key = 'checks/' + crypto.randomUUID(); await client.send(new PutObjectCommand({ Bucket, Key, Body: Buffer.from('storage-check'), ContentType: 'application/octet-stream' })); try { await client.send(new HeadObjectCommand({ Bucket, Key })); } // Requires object read permission too. finally { await client.send(new DeleteObjectCommand({ Bucket, Key })); } }, async put(id, image) { await client.send(new PutObjectCommand({ Bucket, Key: 'assets/' + id, Body: image.bytes, ContentType: image.mime, ChecksumSHA256: Buffer.from(image.checksum, 'hex').toString('base64'), Metadata: { sha256: image.checksum } })); }, // Derived previews live beside the originals in the same bucket, under their // own prefix, so nothing about credentials, lifecycle or backup changes. async putThumb(id, width, bytes, mime) { await client.send(new PutObjectCommand({ Bucket, Key: thumbKey(id, width), Body: bytes, ContentType: mime })); }, async getThumb(id, width) { try { const response = await client.send(new GetObjectCommand({ Bucket, Key: thumbKey(id, width) })); const chunks = []; for await (const chunk of response.Body) chunks.push(chunk); return { bytes: Buffer.concat(chunks), mime: response.ContentType || 'image/webp' }; } catch (e) { if (e && (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404)) return null; throw e; } }, async get(id) { const response = await client.send(new GetObjectCommand({ Bucket, Key: 'assets/' + id })); if (response.ContentLength > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } const chunks = []; let size = 0; for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); } return inspect(Buffer.concat(chunks), response.ContentType); }, close() { client.destroy(); } }; } module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage };