@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/README.md +11 -4
- package/lib/events-tools.d.ts +44 -0
- package/lib/events-tools.d.ts.map +1 -0
- package/lib/events-tools.js +219 -0
- package/lib/events-tools.js.map +1 -0
- package/lib/files-tools.d.ts +55 -0
- package/lib/files-tools.d.ts.map +1 -0
- package/lib/files-tools.js +259 -0
- package/lib/files-tools.js.map +1 -0
- package/lib/index.d.ts +16 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -3
- package/lib/index.js.map +1 -1
- package/lib/notify-tools.d.ts +18 -0
- package/lib/notify-tools.d.ts.map +1 -0
- package/lib/notify-tools.js +52 -0
- package/lib/notify-tools.js.map +1 -0
- package/lib/secrets-tools.d.ts +4 -0
- package/lib/secrets-tools.d.ts.map +1 -1
- package/lib/secrets-tools.js +3 -1
- package/lib/secrets-tools.js.map +1 -1
- package/lib/sigv4.d.ts +37 -0
- package/lib/sigv4.d.ts.map +1 -0
- package/lib/sigv4.js +94 -0
- package/lib/sigv4.js.map +1 -0
- package/lib/weather-tools.d.ts +6 -0
- package/lib/weather-tools.d.ts.map +1 -1
- package/lib/weather-tools.js +1 -5
- package/lib/weather-tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/dsh-righthand/SKILL.md +27 -3
- package/src/events-tools.ts +241 -0
- package/src/files-tools.ts +294 -0
- package/src/index.ts +19 -3
- package/src/notify-tools.ts +62 -0
- package/src/secrets-tools.ts +3 -1
- package/src/sigv4.ts +122 -0
- package/src/weather-tools.ts +8 -2
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
|
+
|
package/src/weather-tools.ts
CHANGED
|
@@ -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')
|