feat: S3 client compatibility — virtual-hosted style, CORS, presigned multi-method, ListMultipartUploads, edge case fixes
- Virtual-hosted bucket detection from Host header (extractS3BucketFromHost) - S3 CORS headers + OPTIONS preflight + x-amz-id-2/HostId everywhere - Presigned GET/PUT/HEAD/DELETE via centralized auth (no GET-only restriction) - ListMultipartUploads with DB/xml helpers - UploadPart partNumber range validation (1-10000) - CompleteMultipartUpload ETag matching and ascending order validation - DeleteObjects quiet mode support - CopyObject URL-decode and conditional if-match/if-none-match - encoding-type=url support in ListObjects V1/V2 XML - Safe range-based prefix matching (replaces SQL LIKE) - STREAMING-AWS4-HMAC-SHA256-PAYLOAD → 501 NotImplemented - Traefik wildcard HostRegex for virtual-hosted style - S3_VHOST_DOMAINS config env var
This commit is contained in:
@@ -174,6 +174,9 @@ export const verifySignature = async (
|
||||
const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams);
|
||||
|
||||
const contentSha256 = headers['x-amz-content-sha256'] || null;
|
||||
if (contentSha256?.startsWith('STREAMING-')) {
|
||||
return { isValid: false, credential: null, errorCode: 'NotImplemented' };
|
||||
}
|
||||
const hashedPayload = await getHashedPayload(body, contentSha256);
|
||||
|
||||
const canonicalRequest = buildCanonicalRequest(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyS3Headers } from './headers';
|
||||
import { contentRange, type RangeParseResult } from './range';
|
||||
|
||||
export interface ObjectPartSource {
|
||||
@@ -97,7 +98,7 @@ export const createGetObjectResponse = async (input: ObjectResponseInput): Promi
|
||||
const end = input.range.type === 'valid' ? input.range.end : input.totalSize - 1;
|
||||
const plannedParts = planParts(input.parts, start, end);
|
||||
const contentLength = end >= start ? end - start + 1 : 0;
|
||||
const headers = baseHeaders(input, contentLength);
|
||||
const headers = applyS3Headers(baseHeaders(input, contentLength), input.reqId);
|
||||
|
||||
if (input.range.type === 'valid') {
|
||||
headers.set('content-range', contentRange(start, end, input.totalSize));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const stripPort = (host: string): string => {
|
||||
if (host.startsWith('[')) return host;
|
||||
return host.split(':')[0].toLowerCase().replace(/\.$/, '');
|
||||
};
|
||||
|
||||
const isValidBucketLabel = (bucket: string): boolean =>
|
||||
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) &&
|
||||
!bucket.includes('..') &&
|
||||
!bucket.includes('.-') &&
|
||||
!bucket.includes('-.');
|
||||
|
||||
export const extractS3BucketFromHost = (host: string, domains: string[]): string | null => {
|
||||
const normalizedHost = stripPort(host);
|
||||
for (const domain of domains) {
|
||||
const normalizedDomain = stripPort(domain);
|
||||
if (!normalizedDomain || normalizedHost === normalizedDomain) continue;
|
||||
if (!normalizedHost.endsWith(`.${normalizedDomain}`)) continue;
|
||||
|
||||
const bucket = normalizedHost.slice(0, -(normalizedDomain.length + 1));
|
||||
return isValidBucketLabel(bucket) ? bucket : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
+53
-15
@@ -1,3 +1,5 @@
|
||||
import { s3Headers } from './headers';
|
||||
|
||||
const escapeXml = (str: string): string =>
|
||||
str
|
||||
.replace(/&/g, '&')
|
||||
@@ -8,6 +10,9 @@ const escapeXml = (str: string): string =>
|
||||
|
||||
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 = (
|
||||
@@ -40,18 +45,20 @@ export const listBucketResultXml = (
|
||||
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>${escapeXml(prefix)}</Prefix>
|
||||
<Marker>${escapeXml(marker || '')}</Marker>
|
||||
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<Delimiter>${escapeXml(delimiter || '')}</Delimiter>
|
||||
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
|
||||
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${objects
|
||||
.map(
|
||||
(o) => `<Contents>
|
||||
<Key>${escapeXml(o.key)}</Key>
|
||||
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
@@ -62,11 +69,11 @@ export const listBucketResultXml = (
|
||||
${prefixes
|
||||
.map(
|
||||
(p) => `<CommonPrefixes>
|
||||
<Prefix>${escapeXml(p)}</Prefix>
|
||||
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||
</CommonPrefixes>`,
|
||||
)
|
||||
.join('')}
|
||||
${nextMarker ? `<NextMarker>${escapeXml(nextMarker)}</NextMarker>` : ''}
|
||||
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
|
||||
</ListBucketResult>`;
|
||||
|
||||
export const listBucketV2ResultXml = (
|
||||
@@ -81,19 +88,21 @@ export const listBucketV2ResultXml = (
|
||||
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>${escapeXml(prefix)}</Prefix>
|
||||
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<KeyCount>${keyCount}</KeyCount>
|
||||
${delimiter ? `<Delimiter>${escapeXml(delimiter)}</Delimiter>` : ''}
|
||||
${continuationToken ? `<ContinuationToken>${escapeXml(continuationToken)}</ContinuationToken>` : ''}
|
||||
${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>${escapeXml(o.key)}</Key>
|
||||
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
@@ -104,11 +113,11 @@ export const listBucketV2ResultXml = (
|
||||
${prefixes
|
||||
.map(
|
||||
(p) => `<CommonPrefixes>
|
||||
<Prefix>${escapeXml(p)}</Prefix>
|
||||
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||
</CommonPrefixes>`,
|
||||
)
|
||||
.join('')}
|
||||
${nextContinuationToken ? `<NextContinuationToken>${escapeXml(nextContinuationToken)}</NextContinuationToken>` : ''}
|
||||
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
|
||||
</ListBucketResultV2>`;
|
||||
|
||||
// ─────── Multipart ───────
|
||||
@@ -151,6 +160,35 @@ export const listPartsXml = (
|
||||
.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,
|
||||
@@ -213,6 +251,7 @@ export const s3ErrorXml = (
|
||||
<Message>${escapeXml(message)}</Message>
|
||||
<Resource>${escapeXml(resource)}</Resource>
|
||||
<RequestId>${requestId}</RequestId>
|
||||
<HostId>${requestId}</HostId>
|
||||
</Error>`;
|
||||
|
||||
export const s3ErrorResponse = (
|
||||
@@ -225,11 +264,10 @@ export const s3ErrorResponse = (
|
||||
): Response =>
|
||||
new Response(s3ErrorXml(code, message, resource, requestId), {
|
||||
status,
|
||||
headers: {
|
||||
headers: s3Headers(requestId, {
|
||||
'content-type': 'application/xml',
|
||||
...(requestId ? { 'x-amz-request-id': requestId } : {}),
|
||||
...extraHeaders,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// ─────── DeleteObjects XML parser ───────
|
||||
|
||||
Reference in New Issue
Block a user