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 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
@@ -0,0 +1,8 @@
1
+ export enum ErrorCode {
2
+ Forbidden = 403,
3
+ UpstreamFailure = 502,
4
+ }
5
+
6
+ export function textErrorResponse(text: string, errorCode: ErrorCode): Response {
7
+ return new Response(text, { status: errorCode, headers: { 'Content-Type': 'text/plain' } });
8
+ }
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
+ }