fix: verify presigned S3 URLs against public host
This commit is contained in:
+14
-11
@@ -160,7 +160,8 @@ export const handleS3Request = async (req: Request): Promise<Response> => {
|
||||
}
|
||||
|
||||
// Standard object operations
|
||||
if (method === 'GET') return handleGetObject(bucket, key, searchParams, reqId);
|
||||
if (method === 'GET')
|
||||
return handleGetObject(bucket, key, searchParams, headers, req.url, reqId);
|
||||
if (method === 'HEAD') return handleHeadObject(bucket, key, reqId);
|
||||
if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId);
|
||||
if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId);
|
||||
@@ -264,21 +265,23 @@ const handleGetObject = async (
|
||||
bucket: string,
|
||||
key: string,
|
||||
searchParams: URLSearchParams,
|
||||
headers: Record<string, string>,
|
||||
requestUrl: string,
|
||||
reqId: string,
|
||||
): Promise<Response> => {
|
||||
if (searchParams.has('X-Amz-Signature')) {
|
||||
const fullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`;
|
||||
const presignedResult = await verifyPresignedUrl(
|
||||
fullUrl,
|
||||
'GET',
|
||||
config.s3AccessKey,
|
||||
config.s3SecretKey,
|
||||
REGION,
|
||||
);
|
||||
const presignedResult = await verifyPresignedUrl({
|
||||
url: requestUrl,
|
||||
method: 'GET',
|
||||
headers,
|
||||
s3AccessKey: config.s3AccessKey,
|
||||
s3SecretKey: config.s3SecretKey,
|
||||
region: REGION,
|
||||
});
|
||||
if (!presignedResult.isValid) {
|
||||
return s3ErrorResponse(
|
||||
'AccessDenied',
|
||||
'Request has expired',
|
||||
presignedResult.errorCode || 'AccessDenied',
|
||||
'Presigned URL verification failed',
|
||||
`/${bucket}/${key}`,
|
||||
403,
|
||||
reqId,
|
||||
|
||||
+92
-63
@@ -9,6 +9,16 @@ export interface SigV4Result {
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface VerifyPresignedUrlInput {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
s3AccessKey: string;
|
||||
s3SecretKey: string;
|
||||
region: string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const SERVICE = 's3';
|
||||
const TERMINATION = 'aws4_request';
|
||||
|
||||
@@ -101,16 +111,26 @@ const normalizeUri = (uri: string): string => {
|
||||
return decodeURIComponent(uri);
|
||||
};
|
||||
|
||||
const buildCanonicalQueryString = (searchParams: URLSearchParams): string => {
|
||||
const params: string[] = [];
|
||||
const keys = Array.from(searchParams.keys()).sort();
|
||||
for (const key of keys) {
|
||||
const values = searchParams.getAll(key).sort();
|
||||
for (const value of values) {
|
||||
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
const awsEncode = (value: string): string =>
|
||||
encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
|
||||
export const buildCanonicalQueryString = (
|
||||
searchParams: URLSearchParams,
|
||||
excludeKeys: Set<string> = new Set(),
|
||||
): string => {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (!excludeKeys.has(key)) pairs.push([key, value]);
|
||||
}
|
||||
return params.join('&');
|
||||
pairs.sort(([ak, av], [bk, bv]) => {
|
||||
const a = `${awsEncode(ak)}=${awsEncode(av)}`;
|
||||
const b = `${awsEncode(bk)}=${awsEncode(bv)}`;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&');
|
||||
};
|
||||
|
||||
const getHashedPayload = async (
|
||||
@@ -191,88 +211,97 @@ export const verifySignature = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyPresignedUrl = async (
|
||||
url: string,
|
||||
method: string,
|
||||
s3AccessKey: string,
|
||||
s3SecretKey: string,
|
||||
region: string,
|
||||
): Promise<SigV4Result> => {
|
||||
const parsedUrl = new URL(url);
|
||||
const queryParams = Object.fromEntries(parsedUrl.searchParams.entries());
|
||||
const parseAmzDateUtc = (amzDate: string): Date | null => {
|
||||
const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
|
||||
if (!match) return null;
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(year, 10),
|
||||
Number.parseInt(month, 10) - 1,
|
||||
Number.parseInt(day, 10),
|
||||
Number.parseInt(hour, 10),
|
||||
Number.parseInt(minute, 10),
|
||||
Number.parseInt(second, 10),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const algorithm = queryParams['X-Amz-Algorithm'];
|
||||
const credential = queryParams['X-Amz-Credential'];
|
||||
const signedHeaders = queryParams['X-Amz-SignedHeaders'];
|
||||
const signature = queryParams['X-Amz-Signature'];
|
||||
const expires = parseInt(queryParams['X-Amz-Expires'] || '0', 10);
|
||||
const amzDate = queryParams['X-Amz-Date'];
|
||||
export const verifyPresignedUrl = async ({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
s3AccessKey,
|
||||
s3SecretKey,
|
||||
region,
|
||||
now = new Date(),
|
||||
}: VerifyPresignedUrlInput): Promise<SigV4Result> => {
|
||||
const parsedUrl = new URL(url);
|
||||
const searchParams = parsedUrl.searchParams;
|
||||
|
||||
const algorithm = searchParams.get('X-Amz-Algorithm');
|
||||
const credential = searchParams.get('X-Amz-Credential');
|
||||
const signedHeaders = searchParams.get('X-Amz-SignedHeaders');
|
||||
const signature = searchParams.get('X-Amz-Signature');
|
||||
const expiresText = searchParams.get('X-Amz-Expires');
|
||||
const amzDate = searchParams.get('X-Amz-Date');
|
||||
|
||||
if (
|
||||
!algorithm ||
|
||||
algorithm !== 'AWS4-HMAC-SHA256' ||
|
||||
!credential ||
|
||||
!signedHeaders ||
|
||||
!signature ||
|
||||
!expires ||
|
||||
!expiresText ||
|
||||
!amzDate
|
||||
) {
|
||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const dateObj = new Date(
|
||||
parseInt(amzDate.substring(0, 4), 10),
|
||||
parseInt(amzDate.substring(4, 6), 10) - 1,
|
||||
parseInt(amzDate.substring(6, 8), 10),
|
||||
parseInt(amzDate.substring(9, 11), 10),
|
||||
parseInt(amzDate.substring(11, 13), 10),
|
||||
parseInt(amzDate.substring(13, 15), 10),
|
||||
);
|
||||
const expiresMs = expires * 1000;
|
||||
if (Date.now() > dateObj.getTime() + expiresMs) {
|
||||
const expires = Number.parseInt(expiresText, 10);
|
||||
const signedAt = parseAmzDateUtc(amzDate);
|
||||
if (!Number.isFinite(expires) || expires <= 0 || !signedAt) {
|
||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||
}
|
||||
if (now.getTime() > signedAt.getTime() + expires * 1000) {
|
||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||
}
|
||||
|
||||
const credParts = credential.split('/');
|
||||
const presignedAccessKey = credParts[0];
|
||||
if (presignedAccessKey !== s3AccessKey) {
|
||||
if (credParts.length !== 5) {
|
||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||
}
|
||||
const [accessKey, dateStamp, credentialRegion, service, termination] = credParts;
|
||||
if (
|
||||
accessKey !== s3AccessKey ||
|
||||
credentialRegion !== region ||
|
||||
service !== SERVICE ||
|
||||
termination !== TERMINATION
|
||||
) {
|
||||
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
|
||||
}
|
||||
const dateStamp = credParts[1] || amzDate.substring(0, 8);
|
||||
|
||||
const canonicalUri = normalizeUri(parsedUrl.pathname);
|
||||
|
||||
const sortedParams = new URLSearchParams();
|
||||
const paramKeys = Object.keys(queryParams).sort();
|
||||
for (const key of paramKeys) {
|
||||
if (key !== 'X-Amz-Signature') {
|
||||
sortedParams.append(key, queryParams[key]);
|
||||
}
|
||||
}
|
||||
const canonicalQueryString = buildCanonicalQueryString(sortedParams);
|
||||
|
||||
// Build canonical headers for presigned URL — only 'host' is typically signed
|
||||
const signedHeaderList = signedHeaders.split(';').filter(Boolean);
|
||||
const canonicalHeaders = signedHeaderList
|
||||
.map((h) => `${h.toLowerCase()}:${h === 'host' ? parsedUrl.host : ''}\n`)
|
||||
.map((headerName) => {
|
||||
const lower = headerName.toLowerCase();
|
||||
const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || '';
|
||||
return `${lower}:${value.trim()}\n`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const hashedPayload = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`;
|
||||
const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
|
||||
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/s3/aws4_request`;
|
||||
const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`;
|
||||
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
|
||||
|
||||
const signingKey = await getSigningKey(s3SecretKey, dateStamp, region);
|
||||
const expectedSignature = await hmacHex(signingKey, stringToSign);
|
||||
const expectedSignature = await hmacHex(
|
||||
await getSigningKey(s3SecretKey, dateStamp, region),
|
||||
stringToSign,
|
||||
);
|
||||
|
||||
if (expectedSignature !== signature) {
|
||||
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
|
||||
}
|
||||
|
||||
return { isValid: true, credential: null };
|
||||
return { isValid: true, credential: { accessKey, date: dateStamp, region, service } };
|
||||
};
|
||||
|
||||
export const isS3Request = (headers: Record<string, string>): boolean => {
|
||||
|
||||
+88
-7
@@ -73,14 +73,95 @@ describe('S3 Auth (SigV4)', () => {
|
||||
});
|
||||
|
||||
it('rejects malformed presigned URLs', async () => {
|
||||
const result = await verifyPresignedUrl(
|
||||
'http://localhost/bucket/key',
|
||||
'GET',
|
||||
'key',
|
||||
'secret',
|
||||
'us-east-1',
|
||||
);
|
||||
const result = await verifyPresignedUrl({
|
||||
url: 'http://localhost/bucket/key',
|
||||
method: 'GET',
|
||||
headers: { host: 'localhost' },
|
||||
s3AccessKey: 'key',
|
||||
s3SecretKey: 'secret',
|
||||
region: 'us-east-1',
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorCode).toBe('AccessDenied');
|
||||
});
|
||||
|
||||
const sha256hex = (data: string): string => {
|
||||
const h = new Bun.CryptoHasher('sha256');
|
||||
h.update(data);
|
||||
return Array.from(h.digest())
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
};
|
||||
|
||||
const hmacSha256 = (key: Uint8Array, msg: string): Uint8Array => {
|
||||
const h = new Bun.CryptoHasher('sha256', key);
|
||||
h.update(msg);
|
||||
return h.digest();
|
||||
};
|
||||
|
||||
const signingKey = (secret: string, date: string, region: string): Uint8Array => {
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
let k = hmacSha256(enc(`AWS4${secret}`), date);
|
||||
k = hmacSha256(k, region);
|
||||
k = hmacSha256(k, 's3');
|
||||
return hmacSha256(k, 'aws4_request');
|
||||
};
|
||||
|
||||
const hex = (bytes: Uint8Array): string =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
it('verifies presigned GET using the public request host', async () => {
|
||||
const accessKey = 'teleuploader-admin';
|
||||
const secret = 'unit-test-secret';
|
||||
const host = 'upload.example.test';
|
||||
const path = '/bucket/key.txt';
|
||||
const amzDate = '20260707T120000Z';
|
||||
const dateStamp = '20260707';
|
||||
const sp = new URLSearchParams({
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${accessKey}/${dateStamp}/us-east-1/s3/aws4_request`,
|
||||
'X-Amz-Date': amzDate,
|
||||
'X-Amz-Expires': '3600',
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
});
|
||||
const canonicalQs = [...sp.entries()]
|
||||
.sort(([aKey, aVal], [bKey, bVal]) => `${aKey}=${aVal}`.localeCompare(`${bKey}=${bVal}`))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
const canonicalRequest = `GET\n${path}\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
||||
const hashedCanonical = sha256hex(canonicalRequest);
|
||||
const scope = `${dateStamp}/us-east-1/s3/aws4_request`;
|
||||
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${hashedCanonical}`;
|
||||
const sig = hex(hmacSha256(signingKey(secret, dateStamp, 'us-east-1'), stringToSign));
|
||||
sp.set('X-Amz-Signature', sig);
|
||||
|
||||
const result = await verifyPresignedUrl({
|
||||
url: `https://${host}${path}?${sp.toString()}`,
|
||||
method: 'GET',
|
||||
headers: { host },
|
||||
s3AccessKey: accessKey,
|
||||
s3SecretKey: secret,
|
||||
region: 'us-east-1',
|
||||
now: new Date('2026-07-07T12:05:00Z'),
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects presigned URLs signed for a different host', async () => {
|
||||
const result = await verifyPresignedUrl({
|
||||
url: 'https://wrong.example.test/bucket/key.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=teleuploader-admin%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=00',
|
||||
method: 'GET',
|
||||
headers: { host: 'upload.example.test' },
|
||||
s3AccessKey: 'teleuploader-admin',
|
||||
s3SecretKey: 'unit-test-secret',
|
||||
region: 'us-east-1',
|
||||
now: new Date('2026-07-07T12:05:00Z'),
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorCode).toBe('SignatureDoesNotMatch');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user