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
+224 -33
View File
@@ -18,6 +18,7 @@ import {
listMultipartUploadsByBucket,
} from '../../../db/multipart';
import type { File } from '../../../db/schema';
import type { ForwardResult } from '../../../domain/ports/telegram-service';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
@@ -25,7 +26,7 @@ import {
createChunkedObjectResponse,
storeFileInTelegramChunks,
} from '../../../utils/chunked-storage';
import { verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth';
import { verifyBodyHash, verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth';
import { S3_CORS_HEADERS, s3Headers } from '../../../utils/s3/headers';
import { createGetObjectResponse, type ObjectPartSource } from '../../../utils/s3/object-stream';
import { parseRangeHeader, unsatisfiedContentRange } from '../../../utils/s3/range';
@@ -71,7 +72,19 @@ const s3Response = (
status: number,
reqId: string,
extraHeaders: Record<string, string> = {},
): Response => new Response(body, { status, headers: s3Headers(reqId, extraHeaders) });
): Response => {
// Add content-type for empty 200-series responses (not 204 which has no body)
if (
body === null &&
status >= 200 &&
status < 300 &&
status !== 204 &&
!extraHeaders['content-type']
) {
extraHeaders['content-type'] = 'application/xml';
}
return new Response(body, { status, headers: s3Headers(reqId, extraHeaders) });
};
/**
* Builds an S3 OPTIONS preflight response with CORS headers.
@@ -93,7 +106,12 @@ const parseS3Path = (pathname: string): { bucket: string | null; key: string | n
const parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return { bucket: null, key: null };
if (parts.length === 1) return { bucket: parts[0], key: null };
return { bucket: parts[0], key: parts.slice(1).join('/') };
// Decode URI components to match virtual-hosted behavior (H10)
const key = parts
.slice(1)
.map((segment) => decodeURIComponent(segment))
.join('/');
return { bucket: parts[0], key };
};
/**
@@ -240,7 +258,7 @@ export const handleS3Request = async (
// ── Object-level: Multipart operations ──
if (searchParams.has('uploads') && method === 'POST') {
return handleCreateMultipartUpload(bucket, key, searchParams, reqId);
return handleCreateMultipartUpload(bucket, key, searchParams, headers, reqId);
}
if (searchParams.has('uploadId') && searchParams.has('partNumber') && method === 'PUT') {
return handleUploadPart(bucket, key, searchParams, req, reqId);
@@ -258,7 +276,7 @@ export const handleS3Request = async (
// ── Standard object operations ──
if (method === 'GET') return handleGetObject(bucket, key, searchParams, headers, reqId);
if (method === 'HEAD') return handleHeadObject(bucket, key, reqId);
if (method === 'HEAD') return handleHeadObject(bucket, key, headers, reqId);
if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId);
if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId);
@@ -305,7 +323,13 @@ const handleListBuckets = async (reqId: string): Promise<Response> => {
* @returns An S3 XML response indicating success or failure.
*/
const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Response> => {
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName)) {
// M13: Stricter bucket validation — no consecutive dots, no IP format, no xn-- prefix
if (
!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName) ||
bucketName.includes('..') ||
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bucketName) ||
bucketName.startsWith('xn--')
) {
return s3ErrorResponse(
'InvalidBucketName',
'The specified bucket is not valid.',
@@ -451,6 +475,46 @@ const handleGetObject = async (
reqId,
);
// H3: Conditional headers — If-Match / If-None-Match
const etag = `"${file.fileHash || nanoid(16)}"`;
const ifMatch = headers['if-match'];
if (ifMatch && ifMatch !== '*' && ifMatch !== etag) {
return s3ErrorResponse(
'PreconditionFailed',
'At least one of the pre-conditions you specified did not hold.',
`/${bucket}/${key}`,
412,
reqId,
);
}
const ifNoneMatch = headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === etag) {
return new Response(null, { status: 304 });
}
// H3: Conditional headers — If-Modified-Since / If-Unmodified-Since
const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt);
const ifModifiedSince = headers['if-modified-since'];
if (ifModifiedSince) {
const since = new Date(ifModifiedSince);
if (!Number.isNaN(since.getTime()) && lastModified.getTime() <= since.getTime()) {
return new Response(null, { status: 304 });
}
}
const ifUnmodifiedSince = headers['if-unmodified-since'];
if (ifUnmodifiedSince) {
const since = new Date(ifUnmodifiedSince);
if (!Number.isNaN(since.getTime()) && lastModified.getTime() > since.getTime()) {
return s3ErrorResponse(
'PreconditionFailed',
'At least one of the pre-conditions you specified did not hold.',
`/${bucket}/${key}`,
412,
reqId,
);
}
}
// Chunked storage object
if (file.storageBackend === 'chunked') {
const totalSize = Number(file.sizeBytes);
@@ -488,7 +552,7 @@ const handleGetObject = async (
// Regular Telegram object
const fileInfo = await botPool.getFileInfo(file.telegramFileId);
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
const telegramUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
const totalSize = file.sizeBytes;
const range = parseRangeHeader(headers.range || null, totalSize);
@@ -505,14 +569,13 @@ const handleGetObject = async (
);
}
if (!config.proxyS3Get) {
// Legacy 302 redirect path (when proxy is disabled)
return s3Response(null, 302, reqId, { location: redirectUrl });
}
// H1: Always proxy S3 GETs to avoid leaking the Telegram bot token
// in redirect URLs. The 302 redirect path is removed because the
// URL contains the bot_token — exposing it to clients is a security risk.
const part: ObjectPartSource = {
telegramFileId: file.telegramFileId,
telegramUrl: redirectUrl,
telegramUrl,
sizeBytes: file.sizeBytes,
partNumber: 1,
};
@@ -601,9 +664,7 @@ const handleGetMultipartObject = async (
});
}
if (!config.proxyS3Get) {
return s3Response(null, 302, reqId, { location: sources[0]?.telegramUrl ?? '' });
}
// H1: Always proxy — never expose bot token in redirect URL
try {
return await createGetObjectResponse({
@@ -638,7 +699,12 @@ const handleGetMultipartObject = async (
* @param reqId - The request identifier for S3 headers.
* @returns An S3 response with object metadata headers.
*/
const handleHeadObject = async (bucket: string, key: string, reqId: string): Promise<Response> => {
const handleHeadObject = async (
bucket: string,
key: string,
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
@@ -659,6 +725,46 @@ const handleHeadObject = async (bucket: string, key: string, reqId: string): Pro
reqId,
);
// H3: Conditional headers for HEAD — If-Match / If-None-Match
const etag = `"${file.fileHash || nanoid(16)}"`;
const ifMatch = headers['if-match'];
if (ifMatch && ifMatch !== '*' && ifMatch !== etag) {
return s3ErrorResponse(
'PreconditionFailed',
'At least one of the pre-conditions you specified did not hold.',
`/${bucket}/${key}`,
412,
reqId,
);
}
const ifNoneMatch = headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === etag) {
return new Response(null, { status: 304 });
}
// H3: Conditional headers for HEAD — If-Modified-Since / If-Unmodified-Since
const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt);
const ifModifiedSince = headers['if-modified-since'];
if (ifModifiedSince) {
const since = new Date(ifModifiedSince);
if (!Number.isNaN(since.getTime()) && lastModified.getTime() <= since.getTime()) {
return new Response(null, { status: 304 });
}
}
const ifUnmodifiedSince = headers['if-unmodified-since'];
if (ifUnmodifiedSince) {
const since = new Date(ifUnmodifiedSince);
if (!Number.isNaN(since.getTime()) && lastModified.getTime() > since.getTime()) {
return s3ErrorResponse(
'PreconditionFailed',
'At least one of the pre-conditions you specified did not hold.',
`/${bucket}/${key}`,
412,
reqId,
);
}
}
return s3Response(null, 200, reqId, {
'content-type': file.mimeType,
'content-length': String(file.sizeBytes),
@@ -667,6 +773,7 @@ const handleHeadObject = async (bucket: string, key: string, reqId: string): Pro
file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(),
'accept-ranges': 'bytes',
'cache-control': 'public, max-age=31536000',
'x-amz-version-id': 'null',
});
};
@@ -790,6 +897,31 @@ const handlePutObject = async (
const contentType = headers['content-type'] || 'application/octet-stream';
const streamed = await streamBodyToTemp(req.body);
// H4: Verify body hash against x-amz-content-sha256
const bodyHashError = verifyBodyHash(streamed.fileHash, headers);
if (bodyHashError) {
await cleanupTempFile(streamed.tempPath);
return s3ErrorResponse(
bodyHashError.errorCode || 'BadDigest',
'The Content-MD5 or x-amz-content-sha256 you specified did not match what we received.',
`/${bucket}/${key}`,
400,
reqId,
);
}
// M12: Reject oversized bodies
if (streamed.sizeBytes > config.maxRequestBodyBytes) {
await cleanupTempFile(streamed.tempPath);
return s3ErrorResponse(
'EntityTooLarge',
'Your proposed upload exceeds the maximum allowed object size.',
`/${bucket}/${key}`,
400,
reqId,
);
}
// Idempotent PUT: if the object already exists, skip upload
const existing = await findFileByBucketAndKey(bucketRecord.id, key);
if (existing) {
@@ -948,9 +1080,11 @@ const handleCopyObject = async (
}
// Conditional copy: if-match / if-none-match checks
// M9: Use stable etag (telegramFileId fallback when fileHash is null)
const sourceEtag = sourceFile.fileHash || sourceFile.telegramFileId;
const ifMatch = headers['x-amz-copy-source-if-match'];
const ifNoneMatch = headers['x-amz-copy-source-if-none-match'];
if (ifMatch && sourceFile.fileHash && ifMatch !== `"${sourceFile.fileHash}"`) {
if (ifMatch && ifMatch !== '*' && ifMatch !== `"${sourceEtag}"`) {
return s3ErrorResponse(
'PreconditionFailed',
'The preconditions you specified did not hold.',
@@ -959,7 +1093,7 @@ const handleCopyObject = async (
reqId,
);
}
if (ifNoneMatch && sourceFile.fileHash && ifNoneMatch === `"${sourceFile.fileHash}"`) {
if (ifNoneMatch && ifNoneMatch === `"${sourceEtag}"`) {
return s3ErrorResponse(
'PreconditionFailed',
'The preconditions you specified did not hold.',
@@ -1050,12 +1184,30 @@ const handleDeleteObjects = async (
);
const { keys, quiet } = parseDeleteObjectsBody(body);
// M11: S3 spec limits batch delete to 1000 keys
if (keys.length > 1000) {
return s3ErrorResponse(
'MalformedXML',
'The XML you provided was not well-formed or did not validate against our published schema. Max 1000 keys per request.',
`/${bucket}`,
400,
reqId,
);
}
const deletedKeys: string[] = [];
const errors: Array<{ key: string; code: string; message: string }> = [];
for (const key of keys) {
const ok = await softDeleteFile(bucketRecord.id, key);
if (ok) deletedKeys.push(key);
if (ok) {
deletedKeys.push(key);
} else {
// Per S3 spec, deleting a non-existent key is idempotent — report as success
deletedKeys.push(key);
}
}
const xml = quiet ? deleteResultXml([], []) : deleteResultXml(deletedKeys, []);
const xml = quiet ? deleteResultXml([], []) : deleteResultXml(deletedKeys, errors);
return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};
@@ -1212,6 +1364,7 @@ const handleCreateMultipartUpload = async (
bucket: string,
key: string,
_searchParams: URLSearchParams,
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
@@ -1224,7 +1377,8 @@ const handleCreateMultipartUpload = async (
reqId,
);
const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3');
const contentType = headers['content-type'] || null;
const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3', contentType);
const xml = initiateMultipartUploadXml(bucket, key, uploadId);
return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
@@ -1250,8 +1404,15 @@ const handleUploadPart = async (
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!;
const partNumber = Number.parseInt(searchParams.get('partNumber')!, 10);
if (partNumber < 1 || partNumber > 10000) {
// M14: Validate partNumber is actually an integer, not NaN
const partNumberRaw = searchParams.get('partNumber')!;
const partNumber = Number.parseInt(partNumberRaw, 10);
if (
!Number.isFinite(partNumber) ||
!Number.isInteger(partNumber) ||
partNumber < 1 ||
partNumber > 10000
) {
return s3ErrorResponse(
'InvalidArgument',
'Part number must be an integer between 1 and 10000',
@@ -1315,12 +1476,18 @@ const handleUploadPart = async (
);
}
const forwardResult = await botPool.forwardToStorage(
createReadStream(tempPath),
`mp-${uploadId}-part-${partNumber}`,
'document',
);
// M4: Ensure temp file cleanup even if forwardToStorage fails
let forwardResult: ForwardResult;
try {
forwardResult = await botPool.forwardToStorage(
createReadStream(tempPath),
`mp-${uploadId}-part-${partNumber}`,
'document',
);
} catch (error) {
await cleanupTempFile(tempPath);
throw error;
}
await cleanupTempFile(tempPath);
const etag = hasher.digest('hex');
@@ -1359,7 +1526,8 @@ const handleCompleteMultipartUpload = async (
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId);
if (!multipart) {
// H5: Verify both upload exists AND key matches (consistent with handleUploadPart)
if (!multipart || multipart.s3Key !== key) {
return s3ErrorResponse(
'NoSuchUpload',
'The specified upload does not exist.',
@@ -1384,6 +1552,7 @@ const handleCompleteMultipartUpload = async (
);
}
// H8: Verify count AND part numbers AND etags match stored parts
if (parts.length !== storedParts.length) {
return s3ErrorResponse(
'InvalidPart',
@@ -1394,11 +1563,34 @@ const handleCompleteMultipartUpload = async (
);
}
const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0);
// Build a map for O(1) part number lookup
const storedByNumber = new Map<number, (typeof storedParts)[0]>();
for (const sp of storedParts) {
storedByNumber.set(sp.partNumber, sp);
}
for (const clientPart of parts) {
const stored = storedByNumber.get(clientPart.partNumber);
if (!stored || stored.etag !== clientPart.etag) {
return s3ErrorResponse(
'InvalidPart',
'One or more specified parts could not be found. The etag or part number does not match.',
`/${bucket}/${key}`,
400,
reqId,
);
}
}
const totalSize = storedParts.reduce((sum, p) => sum + Number(p.sizeBytes), 0);
const combinedEtag = storedParts.map((p) => p.etag).join('-');
const publicId = nanoid();
const { db, files: fileSchema } = await import('../../../db/index');
// M7: Use stored content-type from the multipart record if available
const mimeType = multipart.contentType || 'application/octet-stream';
await db.insert(fileSchema).values({
publicId,
telegramFileId: storedParts[0]!.telegramFileId,
@@ -1406,7 +1598,7 @@ const handleCompleteMultipartUpload = async (
storageChatId: config.storageChatId,
storageMessageId: storedParts[0]!.storageMessageId,
fileName: key.split('/').pop() || 'file',
mimeType: 'application/octet-stream',
mimeType,
sizeBytes: totalSize,
fileType: 'document',
uploaderId: 0,
@@ -1422,7 +1614,6 @@ const handleCompleteMultipartUpload = async (
await completeMultipartUpload(uploadId);
const location = `${config.baseUrl}/${bucket}/${key}`;
const combinedEtag = storedParts.map((p) => p.etag).join('-');
const xml = completeMultipartUploadXml(bucket, key, combinedEtag, location);
return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
+10 -44
View File
@@ -1,44 +1,10 @@
export const S3_CORS_HEADERS: Record<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS',
'access-control-allow-headers': [
'Authorization',
'Content-Type',
'Content-MD5',
'Range',
'If-Match',
'If-None-Match',
'If-Modified-Since',
'If-Unmodified-Since',
'X-Amz-*',
'x-amz-*',
].join(', '),
'access-control-expose-headers': [
'Accept-Ranges',
'Content-Length',
'Content-Range',
'Content-Type',
'ETag',
'Last-Modified',
'x-amz-id-2',
'x-amz-request-id',
].join(', '),
'access-control-max-age': '86400',
};
export const s3Headers = (
requestId: string,
extraHeaders: Record<string, string> = {},
): Record<string, string> => ({
...S3_CORS_HEADERS,
...(requestId ? { 'x-amz-request-id': requestId, 'x-amz-id-2': requestId } : {}),
...extraHeaders,
});
export const applyS3Headers = (headers: Headers, requestId: string): Headers => {
const result = new Headers(headers);
for (const [key, value] of Object.entries(s3Headers(requestId))) {
result.set(key, value);
}
return result;
};
/**
* Re-export from the canonical headers implementation.
*
* @module
*/
export {
applyS3Headers,
S3_CORS_HEADERS,
s3Headers,
} from '../../utils/s3/headers';
+22 -309
View File
@@ -1,309 +1,22 @@
import { s3Headers } from './headers';
const escapeXml = (str: string): string =>
str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
const encodeKey = (value: string, encodingType: string | null = null): string =>
encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value);
// ─────── Bucket operations ───────
export const listBucketsXml = (
buckets: { name: string; createdAt: Date }[],
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Buckets>
${buckets
.map(
(b) => `<Bucket>
<Name>${escapeXml(b.name)}</Name>
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
</Bucket>`,
)
.join('')}
</Buckets>
</ListAllMyBucketsResult>`;
export const bucketVersioningConfigurationXml =
(): string => `<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
// ─────── Object listing ───────
export const listBucketResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
marker: string | null,
maxKeys: number,
prefix: string,
delimiter: string | null,
nextMarker: string | null,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
<MaxKeys>${maxKeys}</MaxKeys>
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
</ListBucketResult>`;
export const listBucketV2ResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
maxKeys: number,
prefix: string,
delimiter: string | null,
continuationToken: string | null,
nextContinuationToken: string | null,
keyCount: number,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<MaxKeys>${maxKeys}</MaxKeys>
<KeyCount>${keyCount}</KeyCount>
${delimiter ? `<Delimiter>${encodeKey(delimiter, encodingType)}</Delimiter>` : ''}
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
${continuationToken ? `<ContinuationToken>${encodeKey(continuationToken, encodingType)}</ContinuationToken>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
</ListBucketResultV2>`;
// ─────── Multipart ───────
export const initiateMultipartUploadXml = (
bucketName: string,
key: string,
uploadId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
</InitiateMultipartUploadResult>`;
export const listPartsXml = (
bucketName: string,
key: string,
uploadId: string,
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
maxParts: number,
isTruncated: boolean,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
<MaxParts>${maxParts}</MaxParts>
<IsTruncated>${isTruncated}</IsTruncated>
${parts
.map(
(p) => `<Part>
<PartNumber>${p.partNumber}</PartNumber>
<LastModified>${isoDate(p.createdAt)}</LastModified>
<ETag>"${p.etag}"</ETag>
<Size>${p.sizeBytes}</Size>
</Part>`,
)
.join('')}
</ListPartsResult>`;
export const listMultipartUploadsXml = (
bucketName: string,
uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[],
maxUploads: number,
isTruncated: boolean,
nextKeyMarker: string | null,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<KeyMarker></KeyMarker>
<UploadIdMarker></UploadIdMarker>
${nextKeyMarker ? `<NextKeyMarker>${escapeXml(nextKeyMarker)}</NextKeyMarker>` : ''}
<MaxUploads>${maxUploads}</MaxUploads>
<IsTruncated>${isTruncated}</IsTruncated>
${uploads
.map(
(u) => `<Upload>
<Key>${escapeXml(u.key)}</Key>
<UploadId>${u.uploadId}</UploadId>
<Initiator><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Initiator>
<Owner><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Owner>
<StorageClass>STANDARD</StorageClass>
<Initiated>${isoDate(u.initiatedAt)}</Initiated>
</Upload>`,
)
.join('')}
</ListMultipartUploadsResult>`;
export const completeMultipartUploadXml = (
bucketName: string,
key: string,
etag: string,
location: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Location>${escapeXml(location)}</Location>
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<ETag>"${etag}"</ETag>
</CompleteMultipartUploadResult>`;
// ─────── Delete result ───────
export const deleteResultXml = (
deleted: string[],
errors: { key: string; code: string; message: string }[],
): string => `<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
${deleted
.map(
(key) => `<Deleted>
<Key>${escapeXml(key)}</Key>
</Deleted>`,
)
.join('')}
${errors
.map(
(e) => `<Error>
<Key>${escapeXml(e.key)}</Key>
<Code>${e.code}</Code>
<Message>${escapeXml(e.message)}</Message>
</Error>`,
)
.join('')}
</DeleteResult>`;
// ─────── Copy ───────
export const copyObjectResultXml = (
etag: string,
lastModified: Date,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<ETag>"${etag}"</ETag>
<LastModified>${isoDate(lastModified)}</LastModified>
</CopyObjectResult>`;
// ─────── Error ───────
export const s3ErrorXml = (
code: string,
message: string,
resource: string,
requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>${code}</Code>
<Message>${escapeXml(message)}</Message>
<Resource>${escapeXml(resource)}</Resource>
<RequestId>${requestId}</RequestId>
<HostId>${requestId}</HostId>
</Error>`;
export const s3ErrorResponse = (
code: string,
message: string,
resource: string,
status: number,
requestId: string = '',
extraHeaders: Record<string, string> = {},
): Response =>
new Response(s3ErrorXml(code, message, resource, requestId), {
status,
headers: s3Headers(requestId, {
'content-type': 'application/xml',
...extraHeaders,
}),
});
// ─────── DeleteObjects XML parser ───────
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
return { keys, quiet };
};
// ─────── CompleteMultipartUpload XML parser ───────
export interface CompletePart {
partNumber: number;
etag: string;
}
export const parseCompleteMultipartBody = (body: string): CompletePart[] => {
const parts: CompletePart[] = [];
const partRegex = /<Part>[\s\S]*?<\/Part>/g;
const partMatch = body.match(partRegex) || [];
for (const partXml of partMatch) {
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
if (numMatch && etagMatch) {
parts.push({
partNumber: parseInt(numMatch[1], 10),
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
});
}
}
return parts;
};
/**
* Re-export from the canonical XML implementation.
*
* @module
*/
export {
bucketVersioningConfigurationXml,
type CompletePart,
completeMultipartUploadXml,
copyObjectResultXml,
deleteResultXml,
initiateMultipartUploadXml,
listBucketResultXml,
listBucketsXml,
listBucketV2ResultXml,
listMultipartUploadsXml,
listPartsXml,
parseCompleteMultipartBody,
parseDeleteObjectsBody,
s3ErrorResponse,
s3ErrorXml,
} from '../../utils/s3/xml';