@openape/proxy 0.1.1 → 0.2.0
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/.changeset/README.md +3 -0
- package/.changeset/config.json +10 -0
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/release.yml +53 -0
- package/.github/workflows/security.yml +46 -0
- package/.nvmrc +1 -0
- package/LICENSE +242 -21
- package/README.md +141 -2
- package/bun.lock +229 -0
- package/config.toml +35 -0
- package/eslint.config.mjs +32 -0
- package/package.json +16 -4
- package/pnpm-workspace.yaml +7 -0
- package/src/auth.ts +23 -4
- package/src/config.ts +58 -2
- package/src/connect.ts +111 -0
- package/src/grants-client.ts +22 -9
- package/src/index.ts +30 -23
- package/src/proxy.ts +212 -30
- package/src/ssrf.ts +85 -0
- package/src/types.ts +22 -1
- package/test/auth.test.ts +57 -0
- package/test/connect.test.ts +131 -0
- package/test/matcher.test.ts +46 -0
- package/test/multi-agent.test.ts +122 -0
- package/test/ssrf.test.ts +73 -0
- package/vitest.config.ts +7 -0
package/src/grants-client.ts
CHANGED
|
@@ -33,6 +33,7 @@ export class GrantsClient {
|
|
|
33
33
|
grantType: GrantType
|
|
34
34
|
permissions?: string[]
|
|
35
35
|
reason?: string
|
|
36
|
+
requestHash?: string
|
|
36
37
|
duration?: number
|
|
37
38
|
}): Promise<OpenApeGrant> {
|
|
38
39
|
const res = await fetch(`${this.idpUrl}/api/grants`, {
|
|
@@ -44,6 +45,7 @@ export class GrantsClient {
|
|
|
44
45
|
grant_type: opts.grantType,
|
|
45
46
|
permissions: opts.permissions,
|
|
46
47
|
reason: opts.reason,
|
|
48
|
+
request_hash: opts.requestHash,
|
|
47
49
|
duration: opts.duration,
|
|
48
50
|
}),
|
|
49
51
|
})
|
|
@@ -87,6 +89,8 @@ export class GrantsClient {
|
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* Check if there's an existing approved grant for a domain+permissions combo.
|
|
92
|
+
* Only matches grants whose target matches the requested domain and that
|
|
93
|
+
* have matching permissions. Ignores `allow_once` grants (they're single-use).
|
|
90
94
|
*/
|
|
91
95
|
async findExistingGrant(
|
|
92
96
|
requester: string,
|
|
@@ -95,12 +99,8 @@ export class GrantsClient {
|
|
|
95
99
|
): Promise<OpenApeGrant | null> {
|
|
96
100
|
const params = new URLSearchParams({
|
|
97
101
|
requester,
|
|
98
|
-
target,
|
|
99
102
|
status: 'approved',
|
|
100
103
|
})
|
|
101
|
-
if (permissions?.length) {
|
|
102
|
-
params.set('permissions', permissions.join(','))
|
|
103
|
-
}
|
|
104
104
|
|
|
105
105
|
const res = await fetch(`${this.idpUrl}/api/grants?${params}`, {
|
|
106
106
|
headers: this.headers(),
|
|
@@ -109,10 +109,23 @@ export class GrantsClient {
|
|
|
109
109
|
if (!res.ok) return null
|
|
110
110
|
|
|
111
111
|
const grants = await res.json() as OpenApeGrant[]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
const now = Math.floor(Date.now() / 1000)
|
|
113
|
+
|
|
114
|
+
return grants.find((g) => {
|
|
115
|
+
// Must be approved
|
|
116
|
+
if (g.status !== 'approved') return false
|
|
117
|
+
// Must not be expired
|
|
118
|
+
if (g.expires_at && g.expires_at <= now) return false
|
|
119
|
+
// allow_once grants are single-use — don't reuse them
|
|
120
|
+
if (g.request?.grant_type === 'once') return false
|
|
121
|
+
// Target must match the requested domain
|
|
122
|
+
if (g.request?.target !== target) return false
|
|
123
|
+
// If permissions specified, grant must cover them
|
|
124
|
+
if (permissions?.length && g.request?.permissions?.length) {
|
|
125
|
+
const grantedPerms = new Set(g.request.permissions)
|
|
126
|
+
if (!permissions.every(p => grantedPerms.has(p))) return false
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}) ?? null
|
|
117
130
|
}
|
|
118
131
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,62 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { createServer } from 'node:http'
|
|
2
3
|
import { parseArgs } from 'node:util'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { loadMultiAgentConfig } from './config.js'
|
|
5
|
+
import { createNodeHandler } from './proxy.js'
|
|
5
6
|
import { initAudit } from './audit.js'
|
|
6
7
|
|
|
7
8
|
const { values } = parseArgs({
|
|
8
9
|
options: {
|
|
9
10
|
config: { type: 'string', short: 'c', default: 'config.toml' },
|
|
10
11
|
'dry-run': { type: 'boolean', default: false },
|
|
12
|
+
'mandatory-auth': { type: 'boolean', default: false },
|
|
11
13
|
},
|
|
12
14
|
})
|
|
13
15
|
|
|
14
16
|
const configPath = values.config!
|
|
15
17
|
|
|
16
18
|
console.log(`[openape-proxy] Loading config from ${configPath}`)
|
|
17
|
-
const config =
|
|
19
|
+
const config = loadMultiAgentConfig(configPath, {
|
|
20
|
+
mandatoryAuth: values['mandatory-auth'] || undefined,
|
|
21
|
+
})
|
|
18
22
|
|
|
19
23
|
// Init audit log
|
|
20
24
|
initAudit(config.proxy.audit_log)
|
|
21
25
|
|
|
22
26
|
if (values['dry-run']) {
|
|
23
27
|
console.log('[openape-proxy] DRY RUN mode — logging only, not blocking')
|
|
24
|
-
// In dry-run mode, we could override deny rules to just log
|
|
25
|
-
// For now, just print config and exit
|
|
26
28
|
console.log('[openape-proxy] Config loaded:')
|
|
27
29
|
console.log(` Listen: ${config.proxy.listen}`)
|
|
28
|
-
console.log(` IdP: ${config.proxy.idp_url}`)
|
|
29
|
-
console.log(` Agent: ${config.proxy.agent_email}`)
|
|
30
30
|
console.log(` Default action: ${config.proxy.default_action}`)
|
|
31
|
-
console.log(`
|
|
32
|
-
console.log(`
|
|
33
|
-
|
|
31
|
+
console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
|
|
32
|
+
console.log(` Agents: ${config.agents.length}`)
|
|
33
|
+
for (const agent of config.agents) {
|
|
34
|
+
const allowCount = agent.allow?.length ?? 0
|
|
35
|
+
const denyCount = agent.deny?.length ?? 0
|
|
36
|
+
const grantCount = agent.grant_required?.length ?? 0
|
|
37
|
+
console.log(` ${agent.email} (${agent.idp_url}) — ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`)
|
|
38
|
+
}
|
|
34
39
|
process.exit(0)
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const
|
|
42
|
+
const handler = createNodeHandler(config)
|
|
38
43
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
hostname: proxy.hostname,
|
|
42
|
-
fetch: proxy.fetch,
|
|
43
|
-
})
|
|
44
|
+
const port = Number.parseInt(config.proxy.listen.split(':')[1] || '9090')
|
|
45
|
+
const hostname = config.proxy.listen.split(':')[0] || '127.0.0.1'
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
console.log(`[openape-proxy]
|
|
47
|
+
const server = createServer(handler.handleRequest)
|
|
48
|
+
server.on('connect', handler.handleConnect)
|
|
49
|
+
|
|
50
|
+
server.listen(port, hostname, () => {
|
|
51
|
+
console.log(`[openape-proxy] Listening on http://${hostname}:${port}`)
|
|
52
|
+
console.log(`[openape-proxy] CONNECT tunneling enabled`)
|
|
53
|
+
console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
|
|
54
|
+
console.log(`[openape-proxy] Agents: ${config.agents.map(a => a.email).join(', ')}`)
|
|
55
|
+
console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`)
|
|
56
|
+
})
|
|
50
57
|
|
|
51
58
|
// Graceful shutdown
|
|
52
59
|
process.on('SIGINT', () => {
|
|
53
60
|
console.log('\n[openape-proxy] Shutting down...')
|
|
54
|
-
server.
|
|
61
|
+
server.close()
|
|
55
62
|
process.exit(0)
|
|
56
63
|
})
|
|
57
64
|
|
|
58
65
|
process.on('SIGTERM', () => {
|
|
59
66
|
console.log('[openape-proxy] Shutting down...')
|
|
60
|
-
server.
|
|
67
|
+
server.close()
|
|
61
68
|
process.exit(0)
|
|
62
69
|
})
|
package/src/proxy.ts
CHANGED
|
@@ -1,14 +1,59 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import type { AgentConfig, AuditEntry, MultiAgentProxyConfig, ProxyConfig } from './types.js'
|
|
2
5
|
import { evaluateRules } from './matcher.js'
|
|
3
|
-
import { verifyAgentAuth } from './auth.js'
|
|
6
|
+
import { AuthError, verifyAgentAuth } from './auth.js'
|
|
4
7
|
import { GrantsClient } from './grants-client.js'
|
|
5
8
|
import { writeAudit } from './audit.js'
|
|
9
|
+
import { isPrivateOrLoopback } from './ssrf.js'
|
|
10
|
+
import { handleConnect } from './connect.js'
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Compute a request hash that uniquely identifies the intent.
|
|
14
|
+
* hash = sha256(METHOD + " " + FULL_URL + "\n" + BODY)
|
|
15
|
+
* This binds the grant to the exact request — no bait-and-switch.
|
|
16
|
+
*/
|
|
17
|
+
async function computeRequestHash(method: string, targetUrl: string, body: ArrayBuffer | null): Promise<string> {
|
|
18
|
+
const hash = createHash('sha256')
|
|
19
|
+
hash.update(`${method} ${targetUrl}\n`)
|
|
20
|
+
if (body && body.byteLength > 0) {
|
|
21
|
+
hash.update(new Uint8Array(body))
|
|
22
|
+
}
|
|
23
|
+
return hash.digest('hex')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Legacy single-agent proxy */
|
|
7
27
|
export function createProxy(config: ProxyConfig) {
|
|
8
|
-
const
|
|
28
|
+
const multiConfig: MultiAgentProxyConfig = {
|
|
29
|
+
proxy: {
|
|
30
|
+
listen: config.proxy.listen,
|
|
31
|
+
default_action: config.proxy.default_action,
|
|
32
|
+
audit_log: config.proxy.audit_log,
|
|
33
|
+
mandatory_auth: config.proxy.mandatory_auth,
|
|
34
|
+
},
|
|
35
|
+
agents: [{
|
|
36
|
+
email: config.proxy.agent_email,
|
|
37
|
+
idp_url: config.proxy.idp_url,
|
|
38
|
+
allow: config.allow,
|
|
39
|
+
deny: config.deny,
|
|
40
|
+
grant_required: config.grant_required,
|
|
41
|
+
}],
|
|
42
|
+
}
|
|
43
|
+
return createMultiAgentProxy(multiConfig)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Multi-agent proxy with SSRF protection and mandatory auth */
|
|
47
|
+
export function createMultiAgentProxy(config: MultiAgentProxyConfig) {
|
|
48
|
+
const grantsClients = new Map<string, GrantsClient>()
|
|
49
|
+
for (const agent of config.agents) {
|
|
50
|
+
grantsClients.set(agent.email, new GrantsClient(agent.idp_url))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mandatoryAuth = config.proxy.mandatory_auth ?? false
|
|
9
54
|
|
|
10
55
|
return {
|
|
11
|
-
port: parseInt(config.proxy.listen.split(':')[1] || '9090'),
|
|
56
|
+
port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
|
|
12
57
|
hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
|
|
13
58
|
|
|
14
59
|
async fetch(req: Request): Promise<Response> {
|
|
@@ -17,17 +62,19 @@ export function createProxy(config: ProxyConfig) {
|
|
|
17
62
|
|
|
18
63
|
// Health endpoint
|
|
19
64
|
if (url.pathname === '/healthz') {
|
|
20
|
-
return Response.json({
|
|
65
|
+
return Response.json({
|
|
66
|
+
status: 'ok',
|
|
67
|
+
agents: config.agents.map(a => a.email),
|
|
68
|
+
})
|
|
21
69
|
}
|
|
22
70
|
|
|
23
|
-
// Parse target URL from the path
|
|
24
|
-
// The agent sends: http://proxy:9090/https://api.github.com/repos/x/issues
|
|
25
|
-
// So the target URL is everything after the first /
|
|
71
|
+
// Parse target URL from the path
|
|
26
72
|
const targetUrl = url.pathname.slice(1) + url.search
|
|
27
73
|
let targetParsed: URL
|
|
28
74
|
try {
|
|
29
75
|
targetParsed = new URL(targetUrl)
|
|
30
|
-
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
31
78
|
return new Response(
|
|
32
79
|
'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
|
|
33
80
|
{ status: 400 },
|
|
@@ -38,20 +85,80 @@ export function createProxy(config: ProxyConfig) {
|
|
|
38
85
|
const method = req.method
|
|
39
86
|
const path = targetParsed.pathname
|
|
40
87
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
88
|
+
// SSRF protection — block private/loopback IPs before any rule evaluation
|
|
89
|
+
if (await isPrivateOrLoopback(domain)) {
|
|
90
|
+
return new Response('Blocked: private/loopback IP', { status: 403 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Read body once (needed for hash + forwarding)
|
|
94
|
+
const bodyBuffer = req.body ? await req.arrayBuffer() : null
|
|
46
95
|
|
|
47
|
-
|
|
96
|
+
// Verify agent identity — find IdP URL from first agent (for JWKS verification)
|
|
97
|
+
// In multi-agent mode, we need the JWT to identify the agent first.
|
|
98
|
+
// We try verification against each agent's IdP until one succeeds.
|
|
99
|
+
let agentIdentity: { email: string, act: 'agent' } | null = null
|
|
100
|
+
try {
|
|
101
|
+
for (const agentConf of config.agents) {
|
|
102
|
+
agentIdentity = await verifyAgentAuth(
|
|
103
|
+
req.headers.get('proxy-authorization'),
|
|
104
|
+
agentConf.idp_url,
|
|
105
|
+
mandatoryAuth && config.agents.length === 1,
|
|
106
|
+
)
|
|
107
|
+
if (agentIdentity) break
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If mandatory auth and no identity found from any IdP
|
|
111
|
+
if (mandatoryAuth && !agentIdentity) {
|
|
112
|
+
throw new AuthError('JWT required')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof AuthError) {
|
|
117
|
+
return new Response(`Unauthorized: ${err.message}`, { status: 401 })
|
|
118
|
+
}
|
|
119
|
+
throw err
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find the matching agent config
|
|
123
|
+
const agentEmail = agentIdentity?.email
|
|
124
|
+
let agentConf: AgentConfig | undefined
|
|
125
|
+
|
|
126
|
+
if (agentEmail) {
|
|
127
|
+
agentConf = config.agents.find(a => a.email === agentEmail)
|
|
128
|
+
if (!agentConf) {
|
|
129
|
+
return new Response(`Forbidden: unknown agent ${agentEmail}`, { status: 403 })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (config.agents.length === 1) {
|
|
133
|
+
// Non-mandatory auth, single agent: use the only agent config
|
|
134
|
+
agentConf = config.agents[0]
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
return new Response('Unauthorized: JWT required for multi-agent proxy', { status: 401 })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const effectiveEmail = agentEmail ?? agentConf.email
|
|
141
|
+
const grantsClient = grantsClients.get(agentConf.email)!
|
|
142
|
+
|
|
143
|
+
// Build a ProxyConfig-shaped object for evaluateRules
|
|
144
|
+
const rulesConfig: ProxyConfig = {
|
|
145
|
+
proxy: {
|
|
146
|
+
listen: config.proxy.listen,
|
|
147
|
+
idp_url: agentConf.idp_url,
|
|
148
|
+
agent_email: agentConf.email,
|
|
149
|
+
default_action: config.proxy.default_action,
|
|
150
|
+
},
|
|
151
|
+
allow: agentConf.allow ?? [],
|
|
152
|
+
deny: agentConf.deny ?? [],
|
|
153
|
+
grant_required: agentConf.grant_required ?? [],
|
|
154
|
+
}
|
|
48
155
|
|
|
49
156
|
// Evaluate rules
|
|
50
|
-
const action = evaluateRules(
|
|
157
|
+
const action = evaluateRules(rulesConfig, domain, method, path)
|
|
51
158
|
|
|
52
159
|
const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
|
|
53
160
|
ts: new Date().toISOString(),
|
|
54
|
-
agent:
|
|
161
|
+
agent: effectiveEmail,
|
|
55
162
|
domain,
|
|
56
163
|
method,
|
|
57
164
|
path,
|
|
@@ -66,16 +173,19 @@ export function createProxy(config: ProxyConfig) {
|
|
|
66
173
|
// ALLOW (no grant needed)
|
|
67
174
|
if (action.type === 'allow') {
|
|
68
175
|
writeAudit({ ...baseAudit, action: 'allow', rule: 'allow-list', grant_id: null })
|
|
69
|
-
return forwardRequest(req, targetUrl)
|
|
176
|
+
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
70
177
|
}
|
|
71
178
|
|
|
72
179
|
// GRANT REQUIRED
|
|
73
180
|
const rule = action.rule
|
|
74
181
|
const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`]
|
|
75
182
|
|
|
183
|
+
// Compute request hash — binds grant to exact method + URL + body
|
|
184
|
+
const requestHash = await computeRequestHash(method, targetUrl, bodyBuffer)
|
|
185
|
+
|
|
76
186
|
// Check for existing grant
|
|
77
187
|
const existing = await grantsClient.findExistingGrant(
|
|
78
|
-
|
|
188
|
+
effectiveEmail,
|
|
79
189
|
domain,
|
|
80
190
|
permissions,
|
|
81
191
|
).catch(() => null)
|
|
@@ -86,8 +196,9 @@ export function createProxy(config: ProxyConfig) {
|
|
|
86
196
|
action: 'grant_approved',
|
|
87
197
|
rule: 'standing-grant',
|
|
88
198
|
grant_id: existing.id,
|
|
199
|
+
request_hash: requestHash,
|
|
89
200
|
})
|
|
90
|
-
return forwardRequest(req, targetUrl)
|
|
201
|
+
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
91
202
|
}
|
|
92
203
|
|
|
93
204
|
// No existing grant — behavior depends on default_action
|
|
@@ -99,11 +210,12 @@ export function createProxy(config: ProxyConfig) {
|
|
|
99
210
|
if (config.proxy.default_action === 'request-async') {
|
|
100
211
|
// Create grant request, return 407 immediately
|
|
101
212
|
const grant = await grantsClient.requestGrant({
|
|
102
|
-
requester:
|
|
213
|
+
requester: effectiveEmail,
|
|
103
214
|
target: domain,
|
|
104
215
|
grantType: rule.grant_type,
|
|
105
216
|
permissions,
|
|
106
|
-
reason: `${method} ${
|
|
217
|
+
reason: `${method} ${targetUrl}`,
|
|
218
|
+
requestHash,
|
|
107
219
|
duration: rule.duration,
|
|
108
220
|
}).catch(() => null)
|
|
109
221
|
|
|
@@ -129,11 +241,12 @@ export function createProxy(config: ProxyConfig) {
|
|
|
129
241
|
|
|
130
242
|
try {
|
|
131
243
|
const grant = await grantsClient.requestGrant({
|
|
132
|
-
requester:
|
|
244
|
+
requester: effectiveEmail,
|
|
133
245
|
target: domain,
|
|
134
246
|
grantType: rule.grant_type,
|
|
135
247
|
permissions,
|
|
136
|
-
reason: `${method} ${
|
|
248
|
+
reason: `${method} ${targetUrl}`,
|
|
249
|
+
requestHash,
|
|
137
250
|
duration: rule.duration,
|
|
138
251
|
})
|
|
139
252
|
|
|
@@ -147,9 +260,10 @@ export function createProxy(config: ProxyConfig) {
|
|
|
147
260
|
action: 'grant_approved',
|
|
148
261
|
rule: 'grant_required',
|
|
149
262
|
grant_id: approved.id,
|
|
263
|
+
request_hash: requestHash,
|
|
150
264
|
waited_ms: waitedMs,
|
|
151
265
|
})
|
|
152
|
-
return forwardRequest(req, targetUrl)
|
|
266
|
+
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
153
267
|
}
|
|
154
268
|
|
|
155
269
|
writeAudit({
|
|
@@ -160,8 +274,8 @@ export function createProxy(config: ProxyConfig) {
|
|
|
160
274
|
waited_ms: waitedMs,
|
|
161
275
|
})
|
|
162
276
|
return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
|
|
163
|
-
|
|
164
|
-
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
165
279
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
166
280
|
writeAudit({
|
|
167
281
|
...baseAudit,
|
|
@@ -175,11 +289,78 @@ export function createProxy(config: ProxyConfig) {
|
|
|
175
289
|
}
|
|
176
290
|
}
|
|
177
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Create a node:http compatible handler for use with http.createServer().
|
|
294
|
+
* Returns both a request handler and a CONNECT handler.
|
|
295
|
+
*/
|
|
296
|
+
export function createNodeHandler(config: MultiAgentProxyConfig): {
|
|
297
|
+
handleRequest: (req: IncomingMessage, res: ServerResponse) => void
|
|
298
|
+
handleConnect: (req: IncomingMessage, socket: Socket, head: Buffer) => void
|
|
299
|
+
} {
|
|
300
|
+
const proxy = createMultiAgentProxy(config)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
handleRequest(req: IncomingMessage, res: ServerResponse) {
|
|
304
|
+
// Convert IncomingMessage to Request and use existing fetch logic
|
|
305
|
+
const url = `http://${req.headers.host || 'localhost'}${req.url || '/'}`
|
|
306
|
+
const chunks: Buffer[] = []
|
|
307
|
+
|
|
308
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
309
|
+
req.on('end', async () => {
|
|
310
|
+
try {
|
|
311
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined
|
|
312
|
+
const headers = new Headers()
|
|
313
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
314
|
+
if (value) {
|
|
315
|
+
headers.set(key, Array.isArray(value) ? value.join(', ') : value)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const request = new Request(url, {
|
|
320
|
+
method: req.method,
|
|
321
|
+
headers,
|
|
322
|
+
body: body && req.method !== 'GET' && req.method !== 'HEAD' ? body : undefined,
|
|
323
|
+
duplex: 'half',
|
|
324
|
+
} as RequestInit)
|
|
325
|
+
|
|
326
|
+
const response = await proxy.fetch(request)
|
|
327
|
+
|
|
328
|
+
res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers))
|
|
329
|
+
if (response.body) {
|
|
330
|
+
const reader = response.body.getReader()
|
|
331
|
+
const pump = async (): Promise<void> => {
|
|
332
|
+
const { done, value } = await reader.read()
|
|
333
|
+
if (done) {
|
|
334
|
+
res.end()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
res.write(value)
|
|
338
|
+
return pump()
|
|
339
|
+
}
|
|
340
|
+
await pump()
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
res.end()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
res.writeHead(502)
|
|
348
|
+
res.end('Proxy error')
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
handleConnect(req: IncomingMessage, socket: Socket, head: Buffer) {
|
|
354
|
+
handleConnect(config, req, socket, head)
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
178
359
|
/**
|
|
179
360
|
* Forward a request to the target URL.
|
|
180
361
|
* Strips proxy-specific headers, preserves the rest.
|
|
181
362
|
*/
|
|
182
|
-
async function forwardRequest(originalReq: Request, targetUrl: string): Promise<Response> {
|
|
363
|
+
async function forwardRequest(originalReq: Request, targetUrl: string, cachedBody?: ArrayBuffer | null): Promise<Response> {
|
|
183
364
|
const headers = new Headers(originalReq.headers)
|
|
184
365
|
// Remove proxy-specific headers
|
|
185
366
|
headers.delete('proxy-authorization')
|
|
@@ -187,12 +368,13 @@ async function forwardRequest(originalReq: Request, targetUrl: string): Promise<
|
|
|
187
368
|
// Don't send host of the proxy
|
|
188
369
|
headers.delete('host')
|
|
189
370
|
|
|
371
|
+
const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null
|
|
372
|
+
|
|
190
373
|
try {
|
|
191
374
|
const res = await fetch(targetUrl, {
|
|
192
375
|
method: originalReq.method,
|
|
193
376
|
headers,
|
|
194
|
-
body
|
|
195
|
-
// @ts-expect-error Bun supports duplex
|
|
377
|
+
body,
|
|
196
378
|
duplex: 'half',
|
|
197
379
|
redirect: 'manual',
|
|
198
380
|
})
|
package/src/ssrf.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { resolve4, resolve6 } from 'node:dns/promises'
|
|
2
|
+
import { isIP } from 'node:net'
|
|
3
|
+
|
|
4
|
+
const PRIVATE_RANGES_V4 = [
|
|
5
|
+
{ prefix: 0x7F000000, mask: 0xFF000000 }, // 127.0.0.0/8
|
|
6
|
+
{ prefix: 0x0A000000, mask: 0xFF000000 }, // 10.0.0.0/8
|
|
7
|
+
{ prefix: 0xAC100000, mask: 0xFFF00000 }, // 172.16.0.0/12
|
|
8
|
+
{ prefix: 0xC0A80000, mask: 0xFFFF0000 }, // 192.168.0.0/16
|
|
9
|
+
{ prefix: 0xA9FE0000, mask: 0xFFFF0000 }, // 169.254.0.0/16
|
|
10
|
+
{ prefix: 0x00000000, mask: 0xFF000000 }, // 0.0.0.0/8
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
function ipv4ToNumber(ip: string): number {
|
|
14
|
+
const parts = ip.split('.').map(Number)
|
|
15
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPrivateIPv4(ip: string): boolean {
|
|
19
|
+
const num = ipv4ToNumber(ip)
|
|
20
|
+
return PRIVATE_RANGES_V4.some(r => ((num & r.mask) >>> 0) === r.prefix)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPrivateIPv6(ip: string): boolean {
|
|
24
|
+
const normalized = ip.toLowerCase()
|
|
25
|
+
|
|
26
|
+
// Loopback ::1
|
|
27
|
+
if (normalized === '::1') return true
|
|
28
|
+
|
|
29
|
+
// Unspecified ::
|
|
30
|
+
if (normalized === '::') return true
|
|
31
|
+
|
|
32
|
+
// Link-local fe80::/10
|
|
33
|
+
if (normalized.startsWith('fe8') || normalized.startsWith('fe9')
|
|
34
|
+
|| normalized.startsWith('fea') || normalized.startsWith('feb')) {
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Unique local fd00::/8
|
|
39
|
+
if (normalized.startsWith('fd')) return true
|
|
40
|
+
|
|
41
|
+
// IPv4-mapped ::ffff:x.x.x.x
|
|
42
|
+
const v4mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
|
|
43
|
+
if (v4mapped) return isPrivateIPv4(v4mapped[1])
|
|
44
|
+
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPrivateIP(ip: string): boolean {
|
|
49
|
+
if (isIP(ip) === 4) return isPrivateIPv4(ip)
|
|
50
|
+
if (isIP(ip) === 6) return isPrivateIPv6(ip)
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a hostname resolves to a private or loopback IP.
|
|
56
|
+
* If the hostname is already an IP literal, check directly.
|
|
57
|
+
* Otherwise, resolve via DNS and check all results.
|
|
58
|
+
*/
|
|
59
|
+
export async function isPrivateOrLoopback(hostname: string): Promise<boolean> {
|
|
60
|
+
// Direct IP literal
|
|
61
|
+
if (isIP(hostname)) {
|
|
62
|
+
return isPrivateIP(hostname)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// localhost shortcut
|
|
66
|
+
if (hostname === 'localhost') return true
|
|
67
|
+
|
|
68
|
+
// DNS resolution — check both A and AAAA records
|
|
69
|
+
try {
|
|
70
|
+
const [v4, v6] = await Promise.allSettled([
|
|
71
|
+
resolve4(hostname),
|
|
72
|
+
resolve6(hostname),
|
|
73
|
+
])
|
|
74
|
+
const addrs: string[] = []
|
|
75
|
+
if (v4.status === 'fulfilled') addrs.push(...v4.value)
|
|
76
|
+
if (v6.status === 'fulfilled') addrs.push(...v6.value)
|
|
77
|
+
|
|
78
|
+
if (addrs.length === 0) return true // no records — block to be safe
|
|
79
|
+
return addrs.some(addr => isPrivateIP(addr))
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// DNS failure — block to be safe
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Single-agent proxy configuration (legacy format, parsed from TOML/JSON) */
|
|
2
2
|
export interface ProxyConfig {
|
|
3
3
|
proxy: {
|
|
4
4
|
listen: string
|
|
@@ -6,12 +6,32 @@ export interface ProxyConfig {
|
|
|
6
6
|
agent_email: string
|
|
7
7
|
default_action: 'block' | 'request' | 'request-async'
|
|
8
8
|
audit_log?: string
|
|
9
|
+
mandatory_auth?: boolean
|
|
9
10
|
}
|
|
10
11
|
allow: RuleEntry[]
|
|
11
12
|
deny: RuleEntry[]
|
|
12
13
|
grant_required: GrantRuleEntry[]
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Multi-agent proxy configuration */
|
|
17
|
+
export interface MultiAgentProxyConfig {
|
|
18
|
+
proxy: {
|
|
19
|
+
listen: string
|
|
20
|
+
default_action: 'block' | 'request' | 'request-async'
|
|
21
|
+
audit_log?: string
|
|
22
|
+
mandatory_auth?: boolean
|
|
23
|
+
}
|
|
24
|
+
agents: AgentConfig[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AgentConfig {
|
|
28
|
+
email: string
|
|
29
|
+
idp_url: string
|
|
30
|
+
allow?: RuleEntry[]
|
|
31
|
+
deny?: RuleEntry[]
|
|
32
|
+
grant_required?: GrantRuleEntry[]
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
export interface RuleEntry {
|
|
16
36
|
domain: string
|
|
17
37
|
methods?: string[]
|
|
@@ -38,6 +58,7 @@ export interface AuditEntry {
|
|
|
38
58
|
method: string
|
|
39
59
|
path: string
|
|
40
60
|
grant_id?: string | null
|
|
61
|
+
request_hash?: string
|
|
41
62
|
rule: string
|
|
42
63
|
waited_ms?: number
|
|
43
64
|
error?: string
|