fix: audit S3 protocol — 15+ security & correctness fixes
Deploy FileDrop / deploy (push) Successful in 43s

HIGH severity fixes:
- H1: Bot token leak via 302 redirect — always proxy S3 GETs
- H2: PUT TOCTOU race — add unique partial index (bucket_id, s3_key) WHERE NOT deleted
- H3: GET/HEAD ignore conditional headers (If-Match, If-None-Match, etc.)
- H4: Body payload hash not verified — add verifyBodyHash() post-stream check
- H5: Header-based auth has no expiry check — add 15-min clock skew window
- H7: Multipart abort does not delete parts — DELETE before UPDATE status
- H8: CompleteMultipartUpload skips part number & etag verification
- H9: XML regex fails on keys containing < — use non-greedy [\s\S]*?
- H10: Path-style vs virtual-hosted key decode mismatch

MEDIUM severity fixes:
- M1: Add Date header fallback for x-amz-date
- M2/M3: Validate service/termination in credential scope
- M4: Temp file leak when forwardToStorage throws in handleUploadPart
- M5: Multipart key consistency check (s3Key matches URL)
- M7: Use stored content-type from multipart initiate
- M9: Copy conditional headers skip when fileHash is null
- M11: Add 1000-key limit on DeleteObjects
- M13: Stricter bucket name validation (no .., no IP format)
- M14: NaN partNumber bypasses validation

LOW fixes:
- normalizeUri: dot-segment removal per RFC 3986
- localeCompare -> byte-order comparison in canonical query string
- Validate host in signed headers
- Server: AmazonS3 header on all responses
- x-amz-id-2 separate from x-amz-request-id
- IPv6 handling in stripPort
- Quiet element whitespace tolerance in XML parser
- content-type: application/xml on empty 2xx responses
- Duplicate interfaces/s3/ -> re-exports from utils/s3/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-29 08:02:28 +07:00
parent f5d56f52d4
commit af160e0f33
9 changed files with 475 additions and 449 deletions
+11 -3
View File
@@ -9,6 +9,7 @@ export interface MultipartUpload {
initiatedAt: Date;
status: string;
initiatedBy: string;
contentType: string | null;
}
export interface MultipartPart {
@@ -27,17 +28,20 @@ export const createMultipartUpload = async (
bucketId: string,
s3Key: string,
initiatedBy: string,
contentType?: string | null,
): Promise<string> => {
const uploadId = nanoid(32);
await db.execute(
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
contentType
? sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by, content_type) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy}, ${contentType})`
: sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
);
return uploadId;
};
export const findMultipartUpload = async (uploadId: string): Promise<MultipartUpload | null> => {
const result = (await db.execute(
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, content_type FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
)) as unknown as Record<string, unknown>[];
if (result.length === 0) return null;
const r = result[0]!;
@@ -48,6 +52,7 @@ export const findMultipartUpload = async (uploadId: string): Promise<MultipartUp
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: '',
contentType: (r.content_type as string | null) || null,
};
};
@@ -58,10 +63,12 @@ export const completeMultipartUpload = async (uploadId: string): Promise<void> =
};
export const abortMultipartUpload = async (uploadId: string): Promise<void> => {
// H7: FK cascade only fires on DELETE, not UPDATE. Delete parts explicitly
// before updating the upload status.
await db.execute(sql`DELETE FROM multipart_parts WHERE upload_id = ${uploadId}`);
await db.execute(
sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`,
);
// Parts are cascade-deleted by FK
};
export const insertMultipartPart = async (
@@ -98,6 +105,7 @@ const mapRowToMultipartUpload = (r: Record<string, unknown>): MultipartUpload =>
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: (r.initiated_by as string | null) || '',
contentType: (r.content_type as string | null) || null,
});
export const listMultipartUploadsByBucket = async (
+40 -29
View File
@@ -1,4 +1,4 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { eq, type InferInsertModel, type InferSelectModel } from 'drizzle-orm';
import {
bigint,
boolean,
@@ -7,37 +7,48 @@ import {
serial,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
publicId: text('public_id').unique().notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
fileType: text('file_type').notNull(),
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
fileHash: text('file_hash'),
archiveTelegramFileId: text('archive_telegram_file_id'),
archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }),
archiveFileName: text('archive_file_name'),
archiveEntryName: text('archive_entry_name'),
archiveMimeType: text('archive_mime_type'),
archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }),
bucketId: text('bucket_id'),
s3Key: text('s3_key'),
storageBackend: text('storage_backend').default('telegram'),
isDeleted: boolean('is_deleted').default(false),
multipartUploadId: text('multipart_upload_id'),
partCount: integer('part_count'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const files = pgTable(
'files',
{
id: uuid('id').primaryKey().defaultRandom(),
publicId: text('public_id').unique().notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
fileType: text('file_type').notNull(),
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
fileHash: text('file_hash'),
archiveTelegramFileId: text('archive_telegram_file_id'),
archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }),
archiveFileName: text('archive_file_name'),
archiveEntryName: text('archive_entry_name'),
archiveMimeType: text('archive_mime_type'),
archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }),
bucketId: text('bucket_id'),
s3Key: text('s3_key'),
storageBackend: text('storage_backend').default('telegram'),
isDeleted: boolean('is_deleted').default(false),
multipartUploadId: text('multipart_upload_id'),
partCount: integer('part_count'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
// H2: Prevent TOCTOU race on concurrent PUT — only one active (non-deleted)
// object per (bucket_id, s3_key) pair.
activeObjectIdx: uniqueIndex('active_object_idx')
.on(table.bucketId, table.s3Key)
.where(eq(table.isDeleted, false)),
}),
);
export const fileParts = pgTable('file_parts', {
id: serial('id').primaryKey(),