Upload trực tiếp S3 bằng presigned URL: kiểm checksum, quarantine và chặn ghi đè sau finalize

Tách đường truyền file khỏi API bằng upload intent và presigned PUT. Xác minh checksum theo VersionId để lần ghi đè sau không thay file đã kiểm tra.
Cho file đi qua API là cách triển khai dễ hiểu, nhưng API phải giữ connection và xử lý băng thông cho toàn bộ file. Khi chuyển sang upload trực tiếp object storage, API nhẹ hơn ở đường truyền dữ liệu, còn trách nhiệm kiểm quyền và xác minh file vẫn phải được thiết kế rõ ràng. Một URL có chữ ký không tự biến dữ liệu người dùng tải lên thành nội dung đáng tin.
Bài này triển khai ba bước: tạo upload intent, PUT trực tiếp vào vùng quarantine, và finalize đúng phiên bản object. Lab dùng Node.js 24, PostgreSQL 17 và LocalStack S3 cục bộ. Phần code dừng ở VERIFIED, nghĩa là đã đối chiếu byte; file vẫn private và chưa được công bố là an toàn.
1. Tách đường điều khiển khỏi đường truyền file
Client gửi kích thước và SHA-256 dự kiến tới API. API lấy tenant từ danh tính đã xác thực, tạo object key và lưu intent có hạn. Sau đó client đưa byte trực tiếp tới S3 bằng presigned PUT, rồi gửi intent ID và VersionId về finalizer. Client không được chọn một key bất kỳ để API ký hộ.

AWS mô tả presigned URL là khả năng truy cập tạm thời theo quyền của bên ký; URL có thể được dùng lại khi còn hiệu lực. Vì vậy phải coi URL thật là bearer capability: không ghi nguyên URL vào log, analytics, ticket công khai hoặc nơi có người không được phép đọc.
File nhỏ trong lab chỉ là PNG tối đa 1 MiB. Giới hạn này giúp có thể đọc toàn bộ file để kiểm checksum mà không dùng nhiều bộ nhớ. File lớn cần streaming checksum, giới hạn concurrency, quy trình multipart và xử lý phần upload dang dở. Không bỏ giới hạn chỉ để demo nhận mọi loại file.
2. Dựng database và object storage giả lập
Tạo thư mục lab riêng, lưu file Compose và dùng các cổng 15432, 14566 đang trống. LocalStack được ghim ở 4.14.0 cho thí nghiệm đã chạy; đây là emulator local, không phải khuyến nghị triển khai một bản dịch vụ cloud hoặc thay thế S3 production. Credential test/test chỉ dùng với endpoint loopback.
Image được ghim bằng digest từ lần chạy lab để tránh tag thay đổi làm lệch kết quả. Chỉ chạy cấu hình này trong môi trường thử nghiệm riêng.
File: upload.compose.yaml
name: thanhnh-architecture-20261020
services:
postgres:
image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
environment:
POSTGRES_USER: lab
POSTGRES_PASSWORD: lab-only-password
POSTGRES_DB: arch_lab
ports: ["127.0.0.1:15432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lab -d arch_lab"]
interval: 2s
timeout: 2s
retries: 30
s3:
image: localstack/localstack:4.14.0@sha256:3ebc37595918b8accb852f8048fef2aff047d465167edd655528065b07bc364a
environment:
SERVICES: s3
ports: ["127.0.0.1:14566:4566"]
File: upload-setup.sh
node --version # dùng Node.js 24
npm init -y
npm install pg@8.23.1 @aws-sdk/client-s3@3.1153.0 @aws-sdk/s3-request-presigner@3.1153.0
docker compose up -d --wait
Lưu schema thành uploads.sql. Intent giữ kích thước, checksum, hạn và trạng thái. Chưa có trạng thái APPROVED vì bài không giả lập rằng kiểm chữ ký PNG là quét mã độc. Hệ thống thật có thể cần thêm VERIFYING, REJECTED, EXPIRED và lịch sử kiểm duyệt.
File: uploads.sql
CREATE TABLE IF NOT EXISTS upload_intents (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
object_key text UNIQUE NOT NULL,
expected_size integer NOT NULL CHECK (expected_size BETWEEN 8 AND 1048576),
sha256_base64 text NOT NULL,
status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','VERIFIED')),
version_id text,
expires_at timestamptz NOT NULL
);
File: upload-load.sh
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U lab -d arch_lab < uploads.sql
File: db.mjs
import pg from 'pg';
// Fixed local lab target; this file never reads production .env.
export const pool = new pg.Pool({
connectionString: 'postgres://lab:lab-only-password@127.0.0.1:15432/arch_lab',
max: 10, connectionTimeoutMillis: 2000, statement_timeout: 3000,
});
3. Ký một quyền PUT nhỏ và có phạm vi
Lưu uploads.mjs. Hàm createIntent chỉ nhận metadata đã kiểm tra, tự tạo UUID và prefix quarantine theo tenant. Object key không chứa tên file gốc, email hoặc thông tin người dùng mà storage log có thể lưu lại.
File: uploads.mjs
import { randomUUID, createHash } from 'node:crypto';
import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { pool } from './db.mjs';
export const bucket = 'arch-upload-lab';
export const s3 = new S3Client({ region: 'us-east-1', endpoint: 'http://127.0.0.1:14566',
forcePathStyle: true, credentials: { accessKeyId: 'test', secretAccessKey: 'test' } });
export async function createIntent(tenant, size, sha256) {
if (typeof tenant !== 'string' || typeof sha256 !== 'string' || !/^[a-z0-9-]{1,40}$/.test(tenant) || !Number.isInteger(size) || size < 8 || size > 1048576 ||
!/^[A-Za-z0-9+/]{43}=$/.test(sha256) || Buffer.from(sha256, 'base64').length !== 32) {
throw new Error('invalid upload metadata');
}
const id = randomUUID(), key = `quarantine/${tenant}/${id}.png`;
await pool.query(`INSERT INTO upload_intents(id,tenant_id,object_key,expected_size,sha256_base64,expires_at)
VALUES ($1,$2,$3,$4,$5,now()+interval '5 minutes')`, [id, tenant, key, size, sha256]);
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: bucket, Key: key,
ContentType: 'image/png', ChecksumSHA256: sha256 }), {
expiresIn: 60, unhoistableHeaders: new Set(['x-amz-checksum-sha256']),
});
return { id, url, headers: { 'content-type': 'image/png', 'x-amz-checksum-sha256': sha256 } };
}
export async function finalize(tenant, id, versionId) {
const { rows: [intent] } = await pool.query('SELECT * FROM upload_intents WHERE id=$1 AND tenant_id=$2', [id, tenant]);
if (!intent || !versionId || versionId === 'null') throw new Error('unknown intent or missing immutable version');
if (intent.status === 'VERIFIED') {
if (intent.version_id !== versionId) throw new Error('already finalized with another version');
return { id, status: 'VERIFIED', versionId };
}
if (new Date(intent.expires_at) <= new Date()) throw new Error('intent expired');
const target = { Bucket: bucket, Key: intent.object_key, VersionId: versionId };
const head = await s3.send(new HeadObjectCommand({ ...target, ChecksumMode: 'ENABLED' }),
{ abortSignal: AbortSignal.timeout(2000) });
if (head.ContentLength !== intent.expected_size) throw new Error('size mismatch');
const object = await s3.send(new GetObjectCommand(target), { abortSignal: AbortSignal.timeout(2000) });
const bodyDeadline = AbortSignal.timeout(2000);
const abortBody = () => object.Body.destroy(new Error('object body deadline'));
bodyDeadline.addEventListener('abort', abortBody, { once: true });
let bytes;
try { bytes = await object.Body.transformToByteArray(); }
finally { bodyDeadline.removeEventListener('abort', abortBody); }
if (bytes.length !== intent.expected_size) throw new Error('body size mismatch');
const hash = createHash('sha256').update(bytes).digest('base64');
if (hash !== intent.sha256_base64 || Buffer.from(bytes).subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') {
throw new Error('checksum or PNG signature mismatch');
}
const result = await pool.query(`UPDATE upload_intents SET status='VERIFIED',version_id=$3
WHERE id=$1 AND tenant_id=$2 AND status='PENDING' AND expires_at>now() RETURNING id`, [id, tenant, versionId]);
if (!result.rowCount) {
const { rows: [saved] } = await pool.query('SELECT status,version_id FROM upload_intents WHERE id=$1 AND tenant_id=$2', [id, tenant]);
if (saved?.status !== 'VERIFIED' || saved.version_id !== versionId) throw new Error('finalize conflict or expired');
}
// VERIFIED means byte integrity checked, NOT malware-free or publicly approved.
return { id, status: 'VERIFIED', versionId };
}
Trong ứng dụng thật, client tính hash từ byte trước khi xin intent. API không thể kết luận metadata client đưa là đúng, nhưng có thể ràng buộc nó với lần upload và kiểm lại sau. Header x-amz-checksum-sha256 được giữ ở phần signed headers của URL, còn client phải gửi đúng header như kết quả createIntent trả về.
Tài liệu checksum khi upload của AWS giải thích việc đối chiếu toàn vẹn dữ liệu. Trong mẫu, finalizer vẫn tự hash byte nhận về, thay vì chỉ tin một metadata do client đặt. ETag không được dùng như SHA-256; đặc biệt không giả định ETag luôn là MD5 của toàn file.
Giới hạn kích thước trong code chưa phải hard limit lúc PUT
Mẫu kiểm tra size khi tạo intent và khi finalize. Điều đó không ngăn một client cố gửi file lớn hơn lên storage trong lúc URL còn hạn; nó chỉ ngăn file đó đi tiếp qua finalizer. Nếu cần cưỡng chế khoảng kích thước ngay tại upload, đánh giá presigned POST với policy content-length-range hoặc một đường upload có khả năng enforce phù hợp.
Kích thước file và số intent phải có quota riêng để hạn chế chi phí lưu trữ vùng quarantine. Dù file không được công bố, một người gọi vẫn có thể làm tăng dung lượng hoặc băng thông. Kết hợp rate limit ở API tạo intent với lifecycle và kiểm soát chi phí; không coi một lớp kiểm tra header là đủ.
4. Finalize theo VersionId để không xác minh nhầm file

Client có thể PUT lại cùng key khi URL chưa hết hạn. Nếu finalizer đọc object mới nhất rồi lưu trạng thái “đã kiểm tra” chỉ theo key, một lần ghi đè sau đó có thể thay byte mà người dùng thực sự tải xuống. Mẫu bật versioning và lưu chính xác VersionId đã được xác minh.
S3 Versioning giữ nhiều phiên bản của một key trong general purpose bucket. Khi xử lý hoặc phục vụ file đã duyệt, cần đọc đúng version đã lưu, không bỏ VersionId để lấy latest. Mẫu từ chối version rỗng hoặc null; thiết kế cho bucket không versioning cần một cơ chế chống ghi đè khác.

Finalizer kiểm tenant và intent, HEAD đúng version để biết kích thước, rồi GET cùng version để kiểm byte. HeadObject trả metadata chứ không trả body; 403 cũng không nhất thiết chỉ ra object tồn tại hay không khi quyền list bucket khác nhau. Đừng bỏ qua mọi lỗi HEAD rồi coi client upload thành công.
Phần gọi mạng diễn ra ngoài transaction có khóa DB dài. Khi kiểm chứng xong, UPDATE có điều kiện chỉ đổi PENDING còn hạn sang VERIFIED. Nếu hai finalizer chạy cùng lúc, lần sau chỉ được coi là replay khi trạng thái và version khớp. Nếu intent hết hạn hoặc một version khác đã thắng, hàm trả lỗi thay vì ghi đè quyết định trước.
5. Chạy upload và thử ghi đè cùng key
Lưu uploads-demo.mjs. Script tạo bucket lab, bật versioning, upload một PNG fixture nhỏ và finalize hai lần cùng version để kiểm tra khả năng lặp. Nó không in presigned URL ra log.
File: uploads-demo.mjs
import { createHash } from 'node:crypto';
import { CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
import { s3, bucket, createIntent, finalize } from './uploads.mjs';
import { pool } from './db.mjs';
try {
try { await s3.send(new CreateBucketCommand({ Bucket: bucket })); }
catch (error) { if (error.name !== 'BucketAlreadyOwnedByYou') throw error; }
await s3.send(new PutBucketVersioningCommand({ Bucket: bucket, VersioningConfiguration: { Status: 'Enabled' } }));
// A tiny PNG fixture for the local lab only.
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZ1kAAAAASUVORK5CYII=', 'base64');
const hash = createHash('sha256').update(bytes).digest('base64');
const intent = await createIntent('tenant-a', bytes.length, hash);
const uploaded = await fetch(intent.url, { method: 'PUT', headers: intent.headers, body: bytes });
if (!uploaded.ok) throw new Error(`upload failed ${uploaded.status}`);
const version = uploaded.headers.get('x-amz-version-id');
console.log(await finalize('tenant-a', intent.id, version));
console.log(await finalize('tenant-a', intent.id, version));
} finally { await pool.end(); s3.destroy(); }
File: upload-run.sh
node uploads-demo.mjs
Trong kiểm thử cục bộ, upload thành công và finalize lặp trả cùng phiên bản. Bộ kiểm tra còn PUT lần nữa để tạo version mới, xác nhận finalizer không thay tham chiếu đã xác minh; tenant khác, kích thước không khớp và intent hết hạn đều bị từ chối. Các kiểm tra này chạy trên LocalStack, không phải chứng nhận cho quyền IAM hoặc mọi chi tiết thực thi chữ ký của AWS thật.
Mẫu chỉ kiểm tám byte chữ ký PNG sau checksum. Một tệp có tám byte mở đầu hợp lệ vẫn có thể hỏng, có kích thước giải nén quá lớn hoặc chứa nội dung cần chặn. Muốn cho ảnh đi tới vùng approved, cần decoder đáng tin trong sandbox có giới hạn CPU/RAM, chính sách metadata, quy trình quét phù hợp và một quyết định phê duyệt được lưu lại.
6. CORS, IAM và đường công bố cần triển khai riêng
CORS không cấp quyền truy cập object
Node fetch trong demo không chịu cơ chế CORS như trình duyệt. Khi đưa giao diện web vào, cấu hình origin chính xác, method PUT và các header được dùng, đồng thời expose x-amz-version-id để client lấy phiên bản. AWS phân biệt CORS với kiểm soát truy cập S3; sửa CORS không thay thế quyền IAM hay bucket policy.
File: upload-cors.json
[
{
"AllowedOrigins": ["http://localhost:3000"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "x-amz-checksum-sha256"],
"ExposeHeaders": ["ETag", "x-amz-version-id"],
"MaxAgeSeconds": 300
}
]
JSON trên là ví dụ CORS cho giao diện local ở localhost:3000, chưa được kiểm tra bằng browser trong lab này. Production phải thay bằng origin thật và chỉ expose header cần thiết. Không đổi origin thành dấu sao như một cách sửa nhanh rồi giữ nguyên mà không đánh giá mô hình sử dụng.
Chỉ cấp quyền đúng phần việc
Service ký URL cần quyền PUT ở prefix cho phép. Finalizer cần quyền đọc version tương ứng, và có thể cần quyền KMS bổ sung nếu dùng SSE-KMS. Dùng workload role hoặc cơ chế credential ngắn hạn phù hợp; bỏ endpoint emulator và credential test khi chuyển môi trường. Bucket giữ private, không cấp public-read cho vùng quarantine.
Đường công bố có thể sao chép đúng version đã duyệt sang một key bất biến ở vùng approved, hoặc phục vụ version đã duyệt qua lớp kiểm quyền. Quyết định này cần được gắn với trạng thái kiểm duyệt, không chỉ phản hồi 200 của lệnh PUT. Bài không tự động bật public access hoặc tạo CDN cho file chưa được quét.
7. Vận hành vùng quarantine và xử lý lỗi
- Theo dõi tuổi intent pending, số file đã upload nhưng chưa finalize và dung lượng các version cũ. DB cleanup không tự xóa object, còn lifecycle storage không tự cập nhật DB.
- Đặt lịch đối soát có điều kiện. Trước khi xóa, chắc chắn object/version chưa được tham chiếu bởi nội dung được duyệt hoặc một job đang xử lý.
- Ghi request ID, intent ID, trạng thái và mã lỗi đã lọc. Không log URL có chữ ký, credential, body file hay metadata riêng tư không cần thiết.
- Thử mất response PUT, gọi finalize lặp, checksum sai, đọc version bị xóa và quyền KMS thiếu trên staging thật trước rollout.
- Rollback API ký URL không thu hồi ngay mọi URL đã phát. Khoảng hiệu lực ngắn, quyền gốc và cơ chế chặn truy cập phải được xét trong kế hoạch ứng phó.
Kiến trúc upload trực tiếp chuyển băng thông ra khỏi API, nhưng yêu cầu một hợp đồng rõ từ lúc cấp quyền tới lúc duyệt nội dung. Điểm kiểm soát quan trọng là tham chiếu bất biến và trạng thái được xác nhận bằng dữ liệu thực, không phải tin lời client nói “đã upload xong”.