s3broker 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -0
- package/src/guardrails/guardrails.ts +90 -0
- package/src/guardrails/no-delete-old.ts +63 -0
- package/src/guardrails/s3_helper.ts +16 -0
- package/src/guardrails/type.ts +36 -0
- package/src/index.ts +205 -0
- package/src/sigv4.ts +352 -0
- package/src/types.ts +50 -0
- package/src/utils.ts +8 -0
- package/tsconfig.json +19 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "s3broker",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "S3 proxy library with SigV4 verification and configurable guardrails policies",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"s3",
|
|
12
|
+
"proxy",
|
|
13
|
+
"sigv4",
|
|
14
|
+
"aws",
|
|
15
|
+
"guardrails",
|
|
16
|
+
"security"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"aws4fetch": "^1.0.20",
|
|
21
|
+
"zod": "^4.2.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@cloudflare/workers-types": "^4.20241230.0",
|
|
25
|
+
"typescript": "^5.5.2"
|
|
26
|
+
},
|
|
27
|
+
"author": "Tom Shen"
|
|
28
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AwsClient } from 'aws4fetch';
|
|
2
|
+
import { GuardrailConfig, GuardrailPolicy, GuardrailViolation, UpstreamError } from './type';
|
|
3
|
+
import { NoDeleteOldPolicy } from './no-delete-old';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Evaluate guardrails for a given request
|
|
7
|
+
* @param request The incoming request
|
|
8
|
+
* @param config The guardrail configuration to use
|
|
9
|
+
* @returns The first guardrail violation if the request violates a policy, null otherwise
|
|
10
|
+
*/
|
|
11
|
+
export async function evaluateGuardrails(
|
|
12
|
+
request: Request<unknown, IncomingRequestCfProperties>,
|
|
13
|
+
upstreamFetcher: AwsClient,
|
|
14
|
+
s3_endpoint: string,
|
|
15
|
+
currentTimestampMs: number,
|
|
16
|
+
config: GuardrailConfig,
|
|
17
|
+
): Promise<(GuardrailViolation & { policy: string }) | null> {
|
|
18
|
+
const path = new URL(request.url).pathname;
|
|
19
|
+
const policies = getPolicies(config, path, upstreamFetcher, s3_endpoint, currentTimestampMs);
|
|
20
|
+
|
|
21
|
+
if (policies.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create evaluation promises that include policy name
|
|
26
|
+
const evalPromises = policies.map(({ name: policyName, policy }) => ({
|
|
27
|
+
policyName,
|
|
28
|
+
promise: (async () => {
|
|
29
|
+
const violation = await policy.evaluate(request);
|
|
30
|
+
return violation ? { ...violation, policy: policyName } : null;
|
|
31
|
+
})(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Race until one returns a violation or all are done
|
|
35
|
+
// Using Promise.race with a filter pattern to get first non-null result
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let pendingCount = evalPromises.length;
|
|
38
|
+
|
|
39
|
+
for (const { policyName, promise } of evalPromises) {
|
|
40
|
+
promise
|
|
41
|
+
.then((result) => {
|
|
42
|
+
if (result !== null) {
|
|
43
|
+
// First violation wins
|
|
44
|
+
resolve(result);
|
|
45
|
+
} else {
|
|
46
|
+
pendingCount--;
|
|
47
|
+
if (pendingCount === 0) {
|
|
48
|
+
// All policies passed
|
|
49
|
+
resolve(null);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.catch((error) => {
|
|
54
|
+
// If a policy throws, treat it as a violation
|
|
55
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
if (error instanceof UpstreamError) {
|
|
57
|
+
message = `Error calling upstream when evaluating guardrail: ${message}`;
|
|
58
|
+
}
|
|
59
|
+
resolve({ violation: message, policy: policyName });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get all applicable policies for a given path
|
|
67
|
+
*/
|
|
68
|
+
export function getPolicies(
|
|
69
|
+
config: GuardrailConfig,
|
|
70
|
+
path: string,
|
|
71
|
+
upstreamFetcher: AwsClient,
|
|
72
|
+
upstreamEndpoint: string,
|
|
73
|
+
currentTimestampMs: number,
|
|
74
|
+
): { name: string; policy: GuardrailPolicy }[] {
|
|
75
|
+
const policies: { name: string; policy: GuardrailPolicy }[] = [];
|
|
76
|
+
|
|
77
|
+
// Check noDeleteOld policies - first matching pattern wins
|
|
78
|
+
for (const entry of config.noDeleteOld) {
|
|
79
|
+
const regex = new RegExp(entry.pattern);
|
|
80
|
+
if (regex.test(path)) {
|
|
81
|
+
policies.push({
|
|
82
|
+
name: 'noDeleteOld',
|
|
83
|
+
policy: new NoDeleteOldPolicy(entry.config, upstreamFetcher, upstreamEndpoint, currentTimestampMs),
|
|
84
|
+
});
|
|
85
|
+
break; // First match wins
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return policies;
|
|
90
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { GuardrailPolicy, GuardrailViolation, UpstreamError } from './type';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { AwsClient } from 'aws4fetch';
|
|
4
|
+
import { getObjectMetadata } from './s3_helper';
|
|
5
|
+
|
|
6
|
+
export const NoDeleteOldPolicyConfig = z.object({
|
|
7
|
+
noDeleteBeforeSeconds: z.number().int(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type NoDeleteOldPolicyConfig = z.infer<typeof NoDeleteOldPolicyConfig>;
|
|
11
|
+
|
|
12
|
+
export class NoDeleteOldPolicy implements GuardrailPolicy {
|
|
13
|
+
private config: NoDeleteOldPolicyConfig;
|
|
14
|
+
private upstreamFetcher: AwsClient;
|
|
15
|
+
private upstreamEndpoint: string;
|
|
16
|
+
private currentTimestampMs: number;
|
|
17
|
+
|
|
18
|
+
constructor(config: NoDeleteOldPolicyConfig, upstreamFetcher: AwsClient, upstreamEndpoint: string, currentTimestampMs: number) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.upstreamFetcher = upstreamFetcher;
|
|
21
|
+
this.upstreamEndpoint = upstreamEndpoint;
|
|
22
|
+
this.currentTimestampMs = currentTimestampMs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async evaluate(request: Request<unknown, IncomingRequestCfProperties>): Promise<GuardrailViolation | null> {
|
|
26
|
+
// Only applies to DELETE requests
|
|
27
|
+
if (request.method !== 'DELETE') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const objectPath = this.upstreamEndpoint + url.pathname;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const headers = await getObjectMetadata(this.upstreamFetcher, objectPath);
|
|
36
|
+
|
|
37
|
+
// Get the Last-Modified header to determine object age
|
|
38
|
+
const lastModified = headers.get('Last-Modified');
|
|
39
|
+
if (!lastModified) {
|
|
40
|
+
// If no Last-Modified header, allow deletion (object might be newly created)
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const objectCreatedAtMs = new Date(lastModified).getTime();
|
|
45
|
+
const objectAgeMs = this.currentTimestampMs - objectCreatedAtMs;
|
|
46
|
+
const thresholdMs = this.config.noDeleteBeforeSeconds * 1000;
|
|
47
|
+
|
|
48
|
+
if (objectAgeMs > thresholdMs) {
|
|
49
|
+
return {
|
|
50
|
+
violation: `Cannot delete object: object is ${Math.floor(objectAgeMs / 1000)} seconds old, which exceeds the ${this.config.noDeleteBeforeSeconds} seconds threshold`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof UpstreamError && error.code === '404') {
|
|
57
|
+
// Object doesn't exist, allow deletion attempt (will fail at upstream)
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AwsClient } from 'aws4fetch';
|
|
2
|
+
import { UpstreamError } from './type';
|
|
3
|
+
/**
|
|
4
|
+
* Issue a `HEAD` request to retrieve object metadata
|
|
5
|
+
* @param upstream
|
|
6
|
+
* @param objectPath
|
|
7
|
+
*/
|
|
8
|
+
export async function getObjectMetadata(upstream: AwsClient, objectPath: string): Promise<Headers> {
|
|
9
|
+
const response = await upstream.fetch(objectPath, {
|
|
10
|
+
method: 'HEAD',
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
throw new UpstreamError(response.status.toString(), response.statusText);
|
|
14
|
+
}
|
|
15
|
+
return response.headers;
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z, ZodType } from 'zod';
|
|
2
|
+
import { NoDeleteOldPolicyConfig } from './no-delete-old';
|
|
3
|
+
|
|
4
|
+
export interface GuardrailPolicy {
|
|
5
|
+
evaluate(request: Request<unknown, IncomingRequestCfProperties>): Promise<GuardrailViolation | null>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type GuardrailViolation = {
|
|
9
|
+
violation: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Corresponding config for each object path pattern in regex. First match wins.
|
|
14
|
+
*/
|
|
15
|
+
export const GuardrailPolicyConfigPerPattern = <T extends ZodType>(policy: T) =>
|
|
16
|
+
z.array(
|
|
17
|
+
z.object({
|
|
18
|
+
pattern: z.string(),
|
|
19
|
+
config: policy,
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
export const GuardrailConfig = z.object({
|
|
23
|
+
noDeleteOld: GuardrailPolicyConfigPerPattern(NoDeleteOldPolicyConfig),
|
|
24
|
+
});
|
|
25
|
+
export type GuardrailConfig = z.infer<typeof GuardrailConfig>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Error calling upstream when evaluating guardrails
|
|
29
|
+
*/
|
|
30
|
+
export class UpstreamError extends Error {
|
|
31
|
+
code: string;
|
|
32
|
+
constructor(code: string, message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3Broker - S3 Proxy Library with SigV4 Verification and Guardrails
|
|
3
|
+
*
|
|
4
|
+
* ========== =========== ============
|
|
5
|
+
* ||Client|| -- Key A --> ||S3Broker|| -- Key B --> ||Upstream||
|
|
6
|
+
* ========== =========== ============
|
|
7
|
+
*
|
|
8
|
+
* S3Broker is a library for building secure S3-compatible proxies. It can be used in:
|
|
9
|
+
* - Cloudflare Workers
|
|
10
|
+
* - Any other serverless platforms (Vercel, Netlify, etc.)
|
|
11
|
+
* - Any JavaScript/TypeScript runtime with fetch API support
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* 1. Verifies incoming requests signed with Key A (client credentials)
|
|
15
|
+
* 2. Enforces configurable guardrails policies (e.g., prevent deletion of recent objects)
|
|
16
|
+
* 3. Re-signs requests with Key B (upstream credentials) for the upstream S3 service
|
|
17
|
+
* 4. Proxies the request to any S3-compatible endpoint (AWS S3, Cloudflare R2, MinIO, etc.)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { AwsClient } from 'aws4fetch';
|
|
21
|
+
import { verifySignature } from './sigv4';
|
|
22
|
+
import { textErrorResponse, ErrorCode } from './utils';
|
|
23
|
+
import { evaluateGuardrails } from './guardrails/guardrails';
|
|
24
|
+
import type { S3BrokerOptions } from './types';
|
|
25
|
+
import { GuardrailConfig } from './guardrails/type';
|
|
26
|
+
|
|
27
|
+
// Re-export types
|
|
28
|
+
export type { S3BrokerOptions } from './types';
|
|
29
|
+
export type { GuardrailConfig, GuardrailViolation } from './guardrails/type';
|
|
30
|
+
|
|
31
|
+
// Headers that should be forwarded to upstream (allowlist approach)
|
|
32
|
+
const HEADERS_TO_INCLUDE = new Set([
|
|
33
|
+
// S3-specific headers
|
|
34
|
+
'x-amz-date',
|
|
35
|
+
'x-amz-content-sha256',
|
|
36
|
+
'x-amz-security-token',
|
|
37
|
+
'x-amz-server-side-encryption',
|
|
38
|
+
'x-amz-server-side-encryption-aws-kms-key-id',
|
|
39
|
+
'x-amz-server-side-encryption-customer-algorithm',
|
|
40
|
+
'x-amz-server-side-encryption-customer-key',
|
|
41
|
+
'x-amz-server-side-encryption-customer-key-md5',
|
|
42
|
+
'x-amz-storage-class',
|
|
43
|
+
'x-amz-tagging',
|
|
44
|
+
'x-amz-website-redirect-location',
|
|
45
|
+
'x-amz-acl',
|
|
46
|
+
'x-amz-grant-read',
|
|
47
|
+
'x-amz-grant-write',
|
|
48
|
+
'x-amz-grant-read-acp',
|
|
49
|
+
'x-amz-grant-write-acp',
|
|
50
|
+
'x-amz-grant-full-control',
|
|
51
|
+
'x-amz-metadata-directive',
|
|
52
|
+
'x-amz-copy-source',
|
|
53
|
+
'x-amz-copy-source-if-match',
|
|
54
|
+
'x-amz-copy-source-if-none-match',
|
|
55
|
+
'x-amz-copy-source-if-unmodified-since',
|
|
56
|
+
'x-amz-copy-source-if-modified-since',
|
|
57
|
+
'x-amz-copy-source-range',
|
|
58
|
+
// Standard HTTP headers that S3 uses
|
|
59
|
+
'content-type',
|
|
60
|
+
'content-length',
|
|
61
|
+
'content-md5',
|
|
62
|
+
'content-encoding',
|
|
63
|
+
'content-disposition',
|
|
64
|
+
'cache-control',
|
|
65
|
+
'expires',
|
|
66
|
+
'range',
|
|
67
|
+
'if-match',
|
|
68
|
+
'if-none-match',
|
|
69
|
+
'if-modified-since',
|
|
70
|
+
'if-unmodified-since',
|
|
71
|
+
'user-agent',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// Presigned URL parameters that should be stripped when re-signing
|
|
75
|
+
const PRESIGNED_PARAMS = new Set([
|
|
76
|
+
'X-Amz-Algorithm',
|
|
77
|
+
'X-Amz-Credential',
|
|
78
|
+
'X-Amz-Date',
|
|
79
|
+
'X-Amz-Expires',
|
|
80
|
+
'X-Amz-SignedHeaders',
|
|
81
|
+
'X-Amz-Signature',
|
|
82
|
+
'X-Amz-Security-Token',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
export const defaultGuardrailConfig: GuardrailConfig = {
|
|
86
|
+
noDeleteOld: [
|
|
87
|
+
{
|
|
88
|
+
pattern: '/.*',
|
|
89
|
+
config: {
|
|
90
|
+
noDeleteBeforeSeconds: 60,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle an incoming S3 request with signature verification, guardrails, and proxying.
|
|
98
|
+
*
|
|
99
|
+
* @param request - The incoming HTTP request (must be a valid S3 API request)
|
|
100
|
+
* @param _ctx - Execution context (unused, reserved for future use)
|
|
101
|
+
* @param options - S3Broker configuration options including credentials and guardrails
|
|
102
|
+
* @returns Response from the upstream S3 service, or an error response if validation fails
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* import { handle } from 's3broker';
|
|
107
|
+
*
|
|
108
|
+
* const response = await handle(request, ctx, {
|
|
109
|
+
* s3Endpoint: 'https://my-bucket.s3.amazonaws.com',
|
|
110
|
+
* clientAccessKeyId: 'CLIENT_KEY',
|
|
111
|
+
* clientSecretAccessKey: 'CLIENT_SECRET',
|
|
112
|
+
* upstreamAccessKeyId: 'UPSTREAM_KEY',
|
|
113
|
+
* upstreamSecretAccessKey: 'UPSTREAM_SECRET',
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export async function handle(
|
|
118
|
+
request: Request<unknown, IncomingRequestCfProperties>,
|
|
119
|
+
_ctx: ExecutionContext,
|
|
120
|
+
options: S3BrokerOptions,
|
|
121
|
+
): Promise<Response> {
|
|
122
|
+
const currentTimestamp = Date.now();
|
|
123
|
+
|
|
124
|
+
// Verify the incoming request signature (Client Key)
|
|
125
|
+
const verificationResult = await verifySignature(request, options.clientSecretAccessKey, options.clientAccessKeyId, currentTimestamp);
|
|
126
|
+
|
|
127
|
+
if (!verificationResult.valid) {
|
|
128
|
+
return textErrorResponse(`Signature verification failed: ${verificationResult.error}`, ErrorCode.Forbidden);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Parse the request URL
|
|
132
|
+
const url = new URL(request.url);
|
|
133
|
+
|
|
134
|
+
// Evaluate guardrails
|
|
135
|
+
const guardrailUpstreamClient = new AwsClient({
|
|
136
|
+
accessKeyId: options.upstreamAccessKeyId,
|
|
137
|
+
secretAccessKey: options.upstreamSecretAccessKey,
|
|
138
|
+
retries: 5,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const guardrailViolation = await evaluateGuardrails(
|
|
142
|
+
request,
|
|
143
|
+
guardrailUpstreamClient,
|
|
144
|
+
options.s3Endpoint,
|
|
145
|
+
currentTimestamp,
|
|
146
|
+
options.guardrailConfig || defaultGuardrailConfig,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (guardrailViolation) {
|
|
150
|
+
console.log(`Guardrail violation`, {
|
|
151
|
+
path: url.pathname,
|
|
152
|
+
method: request.method,
|
|
153
|
+
query: url.search,
|
|
154
|
+
policy: guardrailViolation.policy,
|
|
155
|
+
violation: guardrailViolation.violation,
|
|
156
|
+
});
|
|
157
|
+
return textErrorResponse(
|
|
158
|
+
`Request violating guardrail policy ${guardrailViolation.policy}: ${guardrailViolation.violation}`,
|
|
159
|
+
ErrorCode.Forbidden,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Build upstream URL, stripping presigned parameters
|
|
164
|
+
const upstreamUrl = new URL(url.pathname, options.s3Endpoint);
|
|
165
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
166
|
+
if (!PRESIGNED_PARAMS.has(key)) {
|
|
167
|
+
upstreamUrl.searchParams.set(key, value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Build upstream headers (allowlist approach)
|
|
172
|
+
const upstreamHeaders = new Headers();
|
|
173
|
+
for (const [key, value] of request.headers.entries()) {
|
|
174
|
+
if (HEADERS_TO_INCLUDE.has(key.toLowerCase())) {
|
|
175
|
+
upstreamHeaders.set(key, value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
upstreamHeaders.set('x-amz-content-sha256', 'UNSIGNED-PAYLOAD');
|
|
179
|
+
|
|
180
|
+
// Create upstream request
|
|
181
|
+
const upstreamRequest = new Request(upstreamUrl.toString(), {
|
|
182
|
+
method: request.method,
|
|
183
|
+
headers: upstreamHeaders,
|
|
184
|
+
body: request.body,
|
|
185
|
+
// @ts-ignore - duplex is needed for streaming bodies
|
|
186
|
+
duplex: 'half',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Sign and send to upstream
|
|
190
|
+
const proxyUpstreamAws = new AwsClient({
|
|
191
|
+
accessKeyId: options.upstreamAccessKeyId,
|
|
192
|
+
secretAccessKey: options.upstreamSecretAccessKey,
|
|
193
|
+
retries: 0,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return await proxyUpstreamAws.fetch(upstreamRequest);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error('Upstream request failed:', error);
|
|
200
|
+
return textErrorResponse(
|
|
201
|
+
`Upstream request failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
202
|
+
ErrorCode.UpstreamFailure,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/sigv4.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4 verification utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions to verify incoming S3 requests signed with SigV4.
|
|
5
|
+
* It parses the Authorization header, reconstructs the canonical request, and
|
|
6
|
+
* verifies the signature matches what we expect.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface SigV4Params {
|
|
10
|
+
algorithm: string;
|
|
11
|
+
credential: {
|
|
12
|
+
accessKeyId: string;
|
|
13
|
+
date: string;
|
|
14
|
+
region: string;
|
|
15
|
+
service: string;
|
|
16
|
+
};
|
|
17
|
+
signedHeaders: string[];
|
|
18
|
+
signature: string;
|
|
19
|
+
// Presigned URL specific
|
|
20
|
+
expires?: number;
|
|
21
|
+
isPresigned?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if request uses presigned URL authentication
|
|
26
|
+
*/
|
|
27
|
+
export function isPresignedUrl(request: Request): boolean {
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
return url.searchParams.has('X-Amz-Signature');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse presigned URL query parameters
|
|
34
|
+
* Query params: X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders, X-Amz-Signature
|
|
35
|
+
*/
|
|
36
|
+
export function parsePresignedUrl(request: Request): SigV4Params {
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
|
|
39
|
+
const algorithm = url.searchParams.get('X-Amz-Algorithm');
|
|
40
|
+
const credential = url.searchParams.get('X-Amz-Credential');
|
|
41
|
+
const signedHeaders = url.searchParams.get('X-Amz-SignedHeaders');
|
|
42
|
+
const signature = url.searchParams.get('X-Amz-Signature');
|
|
43
|
+
const expires = url.searchParams.get('X-Amz-Expires');
|
|
44
|
+
|
|
45
|
+
if (!algorithm || algorithm !== 'AWS4-HMAC-SHA256') {
|
|
46
|
+
throw new Error('Invalid or missing X-Amz-Algorithm');
|
|
47
|
+
}
|
|
48
|
+
if (!credential || !signedHeaders || !signature) {
|
|
49
|
+
throw new Error('Missing required presigned URL parameters');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Parse credential: accessKeyId/date/region/service/aws4_request
|
|
53
|
+
const credentialParts = credential.split('/');
|
|
54
|
+
if (credentialParts.length !== 5 || credentialParts[4] !== 'aws4_request') {
|
|
55
|
+
throw new Error('Invalid credential format in presigned URL');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
algorithm: 'AWS4-HMAC-SHA256',
|
|
60
|
+
credential: {
|
|
61
|
+
accessKeyId: credentialParts[0],
|
|
62
|
+
date: credentialParts[1],
|
|
63
|
+
region: credentialParts[2],
|
|
64
|
+
service: credentialParts[3],
|
|
65
|
+
},
|
|
66
|
+
signedHeaders: signedHeaders.split(';'),
|
|
67
|
+
signature: signature,
|
|
68
|
+
expires: expires ? parseInt(expires, 10) : undefined,
|
|
69
|
+
isPresigned: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse the AWS SigV4 Authorization header
|
|
75
|
+
* Format: AWS4-HMAC-SHA256 Credential=AKID/20231224/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=...
|
|
76
|
+
*/
|
|
77
|
+
export function parseAuthorizationHeader(authHeader: string): SigV4Params {
|
|
78
|
+
if (!authHeader.startsWith('AWS4-HMAC-SHA256 ')) {
|
|
79
|
+
throw new Error('Invalid authorization header: must start with AWS4-HMAC-SHA256');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Split by comma and trim whitespace to handle both "key=value, key=value" and "key=value,key=value"
|
|
83
|
+
const parts = authHeader
|
|
84
|
+
.substring('AWS4-HMAC-SHA256 '.length)
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((p) => p.trim());
|
|
87
|
+
const params: Record<string, string> = {};
|
|
88
|
+
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
const [key, value] = part.split('=', 2);
|
|
91
|
+
params[key] = value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!params.Credential || !params.SignedHeaders || !params.Signature) {
|
|
95
|
+
throw new Error('Missing required authorization parameters');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parse credential: accessKeyId/date/region/service/aws4_request
|
|
99
|
+
const credentialParts = params.Credential.split('/');
|
|
100
|
+
if (credentialParts.length !== 5 || credentialParts[4] !== 'aws4_request') {
|
|
101
|
+
throw new Error('Invalid credential format');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
algorithm: 'AWS4-HMAC-SHA256',
|
|
106
|
+
credential: {
|
|
107
|
+
accessKeyId: credentialParts[0],
|
|
108
|
+
date: credentialParts[1],
|
|
109
|
+
region: credentialParts[2],
|
|
110
|
+
service: credentialParts[3],
|
|
111
|
+
},
|
|
112
|
+
signedHeaders: params.SignedHeaders.split(';'),
|
|
113
|
+
signature: params.Signature,
|
|
114
|
+
isPresigned: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the canonical request from the incoming request
|
|
120
|
+
* https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
|
|
121
|
+
*
|
|
122
|
+
* @param isPresigned - If true, exclude X-Amz-Signature from query string
|
|
123
|
+
*/
|
|
124
|
+
export async function buildCanonicalRequest(
|
|
125
|
+
request: Request,
|
|
126
|
+
signedHeaders: string[],
|
|
127
|
+
hashedPayload: string,
|
|
128
|
+
isPresigned: boolean = false,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const url = new URL(request.url);
|
|
131
|
+
const httpMethod = request.method;
|
|
132
|
+
|
|
133
|
+
// Canonical URI (path)
|
|
134
|
+
const canonicalUri = url.pathname || '/';
|
|
135
|
+
|
|
136
|
+
// Canonical query string (sorted, exclude X-Amz-Signature for presigned URLs)
|
|
137
|
+
const canonicalQueryString = Array.from(url.searchParams.entries())
|
|
138
|
+
.filter(([key]) => !isPresigned || key !== 'X-Amz-Signature')
|
|
139
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
140
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
141
|
+
.join('&');
|
|
142
|
+
|
|
143
|
+
// Canonical headers (lowercase, sorted, trimmed)
|
|
144
|
+
const headers: Record<string, string> = {};
|
|
145
|
+
for (const headerName of signedHeaders) {
|
|
146
|
+
const value = request.headers.get(headerName);
|
|
147
|
+
if (value !== null) {
|
|
148
|
+
headers[headerName.toLowerCase()] = value.trim();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const canonicalHeaders = signedHeaders.map((name) => `${name.toLowerCase()}:${headers[name.toLowerCase()] || ''}\n`).join('');
|
|
153
|
+
|
|
154
|
+
const canonicalSignedHeaders = signedHeaders.map((h) => h.toLowerCase()).join(';');
|
|
155
|
+
|
|
156
|
+
// Combine into canonical request
|
|
157
|
+
return [httpMethod, canonicalUri, canonicalQueryString, canonicalHeaders, canonicalSignedHeaders, hashedPayload].join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create the string to sign
|
|
162
|
+
* https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
|
|
163
|
+
*/
|
|
164
|
+
export async function createStringToSign(
|
|
165
|
+
algorithm: string,
|
|
166
|
+
requestDate: string,
|
|
167
|
+
credentialScope: string,
|
|
168
|
+
canonicalRequest: string,
|
|
169
|
+
): Promise<string> {
|
|
170
|
+
const encoder = new TextEncoder();
|
|
171
|
+
const canonicalRequestHash = await crypto.subtle.digest('SHA-256', encoder.encode(canonicalRequest));
|
|
172
|
+
const canonicalRequestHashHex = Array.from(new Uint8Array(canonicalRequestHash))
|
|
173
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
174
|
+
.join('');
|
|
175
|
+
|
|
176
|
+
return [algorithm, requestDate, credentialScope, canonicalRequestHashHex].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Derive the signing key
|
|
181
|
+
* https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
|
|
182
|
+
*/
|
|
183
|
+
export async function deriveSigningKey(secretKey: string, date: string, region: string, service: string): Promise<ArrayBuffer> {
|
|
184
|
+
const encoder = new TextEncoder();
|
|
185
|
+
|
|
186
|
+
const kDate = await hmacSha256(encoder.encode('AWS4' + secretKey), encoder.encode(date));
|
|
187
|
+
const kRegion = await hmacSha256(kDate, encoder.encode(region));
|
|
188
|
+
const kService = await hmacSha256(kRegion, encoder.encode(service));
|
|
189
|
+
const kSigning = await hmacSha256(kService, encoder.encode('aws4_request'));
|
|
190
|
+
|
|
191
|
+
return kSigning;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* HMAC-SHA256 helper
|
|
196
|
+
*/
|
|
197
|
+
async function hmacSha256(key: ArrayBuffer | Uint8Array, data: Uint8Array): Promise<ArrayBuffer> {
|
|
198
|
+
const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
199
|
+
return crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Calculate the signature
|
|
204
|
+
*/
|
|
205
|
+
export async function calculateSignature(signingKey: ArrayBuffer, stringToSign: string): Promise<string> {
|
|
206
|
+
const encoder = new TextEncoder();
|
|
207
|
+
const signature = await hmacSha256(signingKey, encoder.encode(stringToSign));
|
|
208
|
+
return Array.from(new Uint8Array(signature))
|
|
209
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
210
|
+
.join('');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Constant-time string comparison using crypto.subtle.timingSafeEqual
|
|
215
|
+
* Prevents timing attacks on signature comparison
|
|
216
|
+
*/
|
|
217
|
+
async function constantTimeCompare(a: string, b: string): Promise<boolean> {
|
|
218
|
+
if (a.length !== b.length) return false;
|
|
219
|
+
|
|
220
|
+
const encoder = new TextEncoder();
|
|
221
|
+
const aBytes = encoder.encode(a);
|
|
222
|
+
const bBytes = encoder.encode(b);
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
return await crypto.subtle.timingSafeEqual(aBytes, bBytes);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse AWS date format (YYYYMMDDTHHMMSSZ) to timestamp (ms)
|
|
233
|
+
*/
|
|
234
|
+
function parseAmzDate(dateStr: string): number {
|
|
235
|
+
const year = parseInt(dateStr.substring(0, 4));
|
|
236
|
+
const month = parseInt(dateStr.substring(4, 6)) - 1;
|
|
237
|
+
const day = parseInt(dateStr.substring(6, 8));
|
|
238
|
+
const hour = parseInt(dateStr.substring(9, 11));
|
|
239
|
+
const minute = parseInt(dateStr.substring(11, 13));
|
|
240
|
+
const second = parseInt(dateStr.substring(13, 15));
|
|
241
|
+
return Date.UTC(year, month, day, hour, minute, second);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Verify the signature of an incoming request
|
|
246
|
+
* Supports both Authorization header and presigned URL authentication
|
|
247
|
+
*/
|
|
248
|
+
export async function verifySignature(
|
|
249
|
+
request: Request,
|
|
250
|
+
clientSecretKey: string,
|
|
251
|
+
expectedAccessKeyId: string,
|
|
252
|
+
currentTimestampMs: number,
|
|
253
|
+
): Promise<{ valid: boolean; error?: string; isPresigned?: boolean }> {
|
|
254
|
+
try {
|
|
255
|
+
const url = new URL(request.url);
|
|
256
|
+
const authHeader = request.headers.get('Authorization');
|
|
257
|
+
const isPresigned = url.searchParams.has('X-Amz-Signature');
|
|
258
|
+
|
|
259
|
+
// Parse auth params from either header or query parameters
|
|
260
|
+
let params: SigV4Params;
|
|
261
|
+
if (isPresigned) {
|
|
262
|
+
params = parsePresignedUrl(request);
|
|
263
|
+
} else if (authHeader) {
|
|
264
|
+
params = parseAuthorizationHeader(authHeader);
|
|
265
|
+
} else {
|
|
266
|
+
return { valid: false, error: 'Missing authentication (no Authorization header or presigned URL parameters)' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Verify access key ID matches
|
|
270
|
+
if (params.credential.accessKeyId !== expectedAccessKeyId) {
|
|
271
|
+
return { valid: false, error: 'Access key ID mismatch' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Get request date early for both staleness and expiration checks
|
|
275
|
+
let requestDate: string | null;
|
|
276
|
+
if (isPresigned) {
|
|
277
|
+
requestDate = url.searchParams.get('X-Amz-Date');
|
|
278
|
+
} else {
|
|
279
|
+
requestDate = request.headers.get('x-amz-date');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!requestDate) {
|
|
283
|
+
return { valid: false, error: 'Missing request date (x-amz-date)' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check request date staleness to prevent replay attacks
|
|
287
|
+
// For presigned URLs, expiration is checked separately below
|
|
288
|
+
if (!isPresigned) {
|
|
289
|
+
const requestDateMs = parseAmzDate(requestDate);
|
|
290
|
+
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; // 5 minutes
|
|
291
|
+
|
|
292
|
+
if (Math.abs(currentTimestampMs - requestDateMs) > MAX_CLOCK_SKEW_MS) {
|
|
293
|
+
return { valid: false, error: 'Request date too old or in future (max 5 min clock skew)' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// For presigned URLs, check expiration
|
|
297
|
+
else if (isPresigned && params.expires !== undefined) {
|
|
298
|
+
const requestDateMs = parseAmzDate(requestDate);
|
|
299
|
+
const expiresAt = requestDateMs + params.expires * 1000;
|
|
300
|
+
|
|
301
|
+
if (currentTimestampMs > expiresAt) {
|
|
302
|
+
return { valid: false, error: 'Presigned URL has expired' };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Get the payload hash
|
|
307
|
+
let payloadHash: string;
|
|
308
|
+
if (isPresigned) {
|
|
309
|
+
// Presigned URLs always use UNSIGNED-PAYLOAD
|
|
310
|
+
payloadHash = 'UNSIGNED-PAYLOAD';
|
|
311
|
+
} else {
|
|
312
|
+
payloadHash = request.headers.get('x-amz-content-sha256') || 'UNSIGNED-PAYLOAD';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check for streaming payload (not supported)
|
|
316
|
+
if (payloadHash === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD') {
|
|
317
|
+
return { valid: false, error: 'Streaming payload signatures are not supported' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Build canonical request
|
|
321
|
+
const canonicalRequest = await buildCanonicalRequest(request, params.signedHeaders, payloadHash, isPresigned);
|
|
322
|
+
|
|
323
|
+
// Create credential scope
|
|
324
|
+
const credentialScope = `${params.credential.date}/${params.credential.region}/${params.credential.service}/aws4_request`;
|
|
325
|
+
|
|
326
|
+
// Create string to sign
|
|
327
|
+
const stringToSign = await createStringToSign(params.algorithm, requestDate, credentialScope, canonicalRequest);
|
|
328
|
+
|
|
329
|
+
// Derive signing key
|
|
330
|
+
const signingKey = await deriveSigningKey(clientSecretKey, params.credential.date, params.credential.region, params.credential.service);
|
|
331
|
+
|
|
332
|
+
// Calculate expected signature
|
|
333
|
+
const expectedSignature = await calculateSignature(signingKey, stringToSign);
|
|
334
|
+
|
|
335
|
+
// Compare signatures using constant-time comparison to prevent timing attacks
|
|
336
|
+
const signatureValid = await constantTimeCompare(expectedSignature, params.signature);
|
|
337
|
+
|
|
338
|
+
if (!signatureValid) {
|
|
339
|
+
return { valid: false, error: 'Signature mismatch', isPresigned };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// SECURITY NOTE: We do not validate the 'host' header against an expected value.
|
|
343
|
+
// If you need to restrict which domains can use these credentials, consider adding:
|
|
344
|
+
// - An environment variable for expected host(s)
|
|
345
|
+
// - Validation that request.headers.get('host') matches the expected value
|
|
346
|
+
// This would prevent credentials from being used on different worker domains.
|
|
347
|
+
|
|
348
|
+
return { valid: true, isPresigned };
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return { valid: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
|
351
|
+
}
|
|
352
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { GuardrailConfig } from './guardrails/type';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the S3Broker handler.
|
|
5
|
+
*
|
|
6
|
+
* S3Broker uses a two-key authentication model:
|
|
7
|
+
* - **Client credentials (Key A)**: Used to verify incoming requests from your clients
|
|
8
|
+
* - **Upstream credentials (Key B)**: Used to sign requests to the upstream S3 service
|
|
9
|
+
*/
|
|
10
|
+
export interface S3BrokerOptions {
|
|
11
|
+
/**
|
|
12
|
+
* The upstream S3-compatible endpoint URL.
|
|
13
|
+
*
|
|
14
|
+
* Supports AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.
|
|
15
|
+
*
|
|
16
|
+
* @example 'https://s3.us-east-1.amazonaws.com'
|
|
17
|
+
* @example 'https://account-id.r2.cloudflarestorage.com'
|
|
18
|
+
*/
|
|
19
|
+
s3Endpoint: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Access Key ID for client authentication (Key A)
|
|
23
|
+
* Used to verify incoming requests
|
|
24
|
+
*/
|
|
25
|
+
clientAccessKeyId: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Secret Access Key for client authentication (Key A)
|
|
29
|
+
* Used to verify incoming requests
|
|
30
|
+
*/
|
|
31
|
+
clientSecretAccessKey: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Access Key ID for upstream authentication (Key B)
|
|
35
|
+
* Used to sign requests to the upstream S3 service
|
|
36
|
+
*/
|
|
37
|
+
upstreamAccessKeyId: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Secret Access Key for upstream authentication (Key B)
|
|
41
|
+
* Used to sign requests to the upstream S3 service
|
|
42
|
+
*/
|
|
43
|
+
upstreamSecretAccessKey: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Optional custom guardrail configuration
|
|
47
|
+
* If not provided, default configuration will be used
|
|
48
|
+
*/
|
|
49
|
+
guardrailConfig?: GuardrailConfig;
|
|
50
|
+
}
|
package/src/utils.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"rootDir": "./src",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"removeComments": false
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|