@try-works/dsh-righthand 0.1.6 → 0.1.8

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/src/sigv4.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * sigv4 — AWS Signature Version 4 for Cloudflare R2 (S3-compatible API).
3
+ * Dependency-free: node:crypto only. Exported pure so tests pin it against
4
+ * the AWS published test vector.
5
+ * @module dsh-righthand/sigv4
6
+ */
7
+
8
+ import { createHmac, createHash } from 'node:crypto'
9
+
10
+ function hmac(key: Buffer | string, data: string): Buffer {
11
+ return createHmac('sha256', key).update(data).digest()
12
+ }
13
+
14
+ function sha256Hex(data: string): string {
15
+ return createHash('sha256').update(data).digest('hex')
16
+ }
17
+
18
+ /** URI-encode one path segment the S3 way. */
19
+ function uriEncode(segment: string): string {
20
+ return encodeURIComponent(segment).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
21
+ }
22
+
23
+ /** Canonical URI: slashes preserved, each segment encoded. */
24
+ function canonicalPath(pathname: string): string {
25
+ if (pathname === '' || pathname === '/') return '/'
26
+ return pathname.split('/').map(uriEncode).join('/')
27
+ }
28
+
29
+ export interface SignRequestInput {
30
+ method: string
31
+ /** Full URL, e.g. https://<account>.r2.cloudflarestorage.com/<bucket>/<key> */
32
+ url: string
33
+ /** Headers to sign (lowercase names), e.g. host, content-type. */
34
+ headers: Record<string, string>
35
+ /** Exact payload bytes; '' for GET/DELETE without a body. */
36
+ payload: string
37
+ accessKeyId: string
38
+ secretAccessKey: string
39
+ /** R2 uses region 'auto'. */
40
+ region: string
41
+ /** yyyyMMdd'T'HHmmss'Z' — injectable for tests. */
42
+ amzDate: string
43
+ }
44
+
45
+ export interface SignedRequest {
46
+ headers: Record<string, string>
47
+ }
48
+
49
+ /**
50
+ * Sign one S3 request. Returns the headers to send, including host,
51
+ * x-amz-content-sha256, x-amz-date and Authorization.
52
+ */
53
+ export function signRequest(input: SignRequestInput): SignedRequest {
54
+ const parsed = new URL(input.url)
55
+ const date = input.amzDate.slice(0, 8)
56
+ const scope = date + '/' + input.region + '/s3/aws4_request'
57
+ const payloadHash = sha256Hex(input.payload)
58
+
59
+ const headers: Record<string, string> = {
60
+ ...input.headers,
61
+ 'x-amz-content-sha256': payloadHash,
62
+ 'x-amz-date': input.amzDate,
63
+ }
64
+ const names = Object.keys(headers).sort()
65
+ const canonicalHeaders = names.map(n => n + ':' + headers[n].trim() + String.fromCharCode(10)).join('')
66
+ const signedHeaders = names.join(';')
67
+ const canonicalRequest = [
68
+ input.method,
69
+ canonicalPath(parsed.pathname),
70
+ parsed.searchParams.toString(),
71
+ canonicalHeaders,
72
+ signedHeaders,
73
+ payloadHash,
74
+ ].join(String.fromCharCode(10))
75
+ const stringToSign = ['AWS4-HMAC-SHA256', input.amzDate, scope, sha256Hex(canonicalRequest)].join(String.fromCharCode(10))
76
+ const kDate = hmac('AWS4' + input.secretAccessKey, date)
77
+ const kRegion = hmac(kDate, input.region)
78
+ const kService = hmac(kRegion, 's3')
79
+ const kSigning = hmac(kService, 'aws4_request')
80
+ const signature = hmac(kSigning, stringToSign).toString('hex')
81
+ const authorization = 'AWS4-HMAC-SHA256 Credential=' + input.accessKeyId + '/' + scope
82
+ + ', SignedHeaders=' + signedHeaders
83
+ + ', Signature=' + signature
84
+ return { headers: { ...headers, authorization } }
85
+ }
86
+
87
+ /**
88
+ * Presign a GET for query-string auth: a shareable URL valid for
89
+ * expiresSeconds (S3 max 7 days).
90
+ */
91
+ export function presignGet(input: Omit<SignRequestInput, 'method' | 'headers' | 'payload'> & { expiresSeconds: number }): string {
92
+ const parsed = new URL(input.url)
93
+ const date = input.amzDate.slice(0, 8)
94
+ const scope = date + '/' + input.region + '/s3/aws4_request'
95
+ const credential = input.accessKeyId + '/' + scope
96
+ const query: Record<string, string> = {
97
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
98
+ 'X-Amz-Credential': credential,
99
+ 'X-Amz-Date': input.amzDate,
100
+ 'X-Amz-Expires': String(input.expiresSeconds),
101
+ 'X-Amz-SignedHeaders': 'host',
102
+ }
103
+ const canonicalQuery = Object.keys(query).sort().map(k => uriEncode(k) + '=' + uriEncode(query[k])).join('&')
104
+ const canonicalRequest = [
105
+ 'GET',
106
+ canonicalPath(parsed.pathname),
107
+ canonicalQuery,
108
+ 'host:' + parsed.host + String.fromCharCode(10),
109
+ 'host',
110
+ 'UNSIGNED-PAYLOAD',
111
+ ].join(String.fromCharCode(10))
112
+ const stringToSign = ['AWS4-HMAC-SHA256', input.amzDate, scope, sha256Hex(canonicalRequest)].join(String.fromCharCode(10))
113
+ const kDate = hmac('AWS4' + input.secretAccessKey, date)
114
+ const kRegion = hmac(kDate, input.region)
115
+ const kService = hmac(kRegion, 's3')
116
+ const kSigning = hmac(kService, 'aws4_request')
117
+ const signature = hmac(kSigning, stringToSign).toString('hex')
118
+ parsed.search = canonicalQuery + '&X-Amz-Signature=' + signature
119
+ return parsed.toString()
120
+ }
121
+
122
+
@@ -65,9 +65,15 @@ export function isBlockedIP(ip: string): boolean {
65
65
  * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
66
66
  * injectable for tests; production uses the globals.
67
67
  */
68
+ export interface GuardedFetchInit {
69
+ method?: string
70
+ body?: string
71
+ headers?: Record<string, string>
72
+ }
73
+
68
74
  export async function guardedFetch(
69
75
  url: string,
70
- deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]> } = {},
76
+ deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]>; init?: GuardedFetchInit } = {},
71
77
  ): Promise<string> {
72
78
  const fetchImpl = deps.fetchImpl ?? fetch
73
79
  const resolve = deps.resolve ?? (async (host: string) => (await lookup(host, { all: true })).map(a => a.address))
@@ -78,7 +84,7 @@ export async function guardedFetch(
78
84
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('blocked: non-http(s) URL')
79
85
  const ips = await resolve(parsed.hostname)
80
86
  if (ips.length === 0 || ips.some(isBlockedIP)) throw new Error('blocked: destination is not a public host')
81
- const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual' })
87
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual', method: deps.init?.method ?? 'GET', body: deps.init?.body, headers: deps.init?.headers })
82
88
  if (res.status >= 300 && res.status < 400) {
83
89
  const next = res.headers.get('location')
84
90
  if (next === null) throw new Error('blocked: redirect without location')