@openape/proxy 0.1.2 → 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/package.json +1 -1
- 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/index.ts +30 -23
- package/src/proxy.ts +178 -22
- package/src/ssrf.ts +85 -0
- package/src/types.ts +21 -1
- package/test/auth.test.ts +57 -0
- package/test/connect.test.ts +131 -0
- package/test/multi-agent.test.ts +122 -0
- package/test/ssrf.test.ts +73 -0
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -5,18 +5,33 @@ export interface AgentIdentity {
|
|
|
5
5
|
act: 'agent'
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export class AuthError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'AuthError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
16
|
* Verify agent JWT from Proxy-Authorization header.
|
|
10
|
-
* Returns the agent identity or null if invalid.
|
|
17
|
+
* Returns the agent identity or null if invalid/missing.
|
|
18
|
+
* When mandatory is true, throws AuthError if no valid JWT is provided.
|
|
11
19
|
*/
|
|
12
20
|
export async function verifyAgentAuth(
|
|
13
21
|
authHeader: string | null,
|
|
14
22
|
idpUrl: string,
|
|
23
|
+
mandatory: boolean = false,
|
|
15
24
|
): Promise<AgentIdentity | null> {
|
|
16
|
-
if (!authHeader)
|
|
25
|
+
if (!authHeader) {
|
|
26
|
+
if (mandatory) throw new AuthError('JWT required')
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
const match = authHeader.match(/^Bearer\s+(.+)$/i)
|
|
19
|
-
if (!match)
|
|
31
|
+
if (!match) {
|
|
32
|
+
if (mandatory) throw new AuthError('Invalid authorization header')
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
20
35
|
|
|
21
36
|
const token = match[1]
|
|
22
37
|
|
|
@@ -25,6 +40,7 @@ export async function verifyAgentAuth(
|
|
|
25
40
|
const { payload } = await verifyJWT(token, jwks, { issuer: idpUrl })
|
|
26
41
|
|
|
27
42
|
if (payload.act !== 'agent' || !payload.sub) {
|
|
43
|
+
if (mandatory) throw new AuthError('Invalid agent token')
|
|
28
44
|
return null
|
|
29
45
|
}
|
|
30
46
|
|
|
@@ -32,7 +48,10 @@ export async function verifyAgentAuth(
|
|
|
32
48
|
email: payload.sub as string,
|
|
33
49
|
act: 'agent',
|
|
34
50
|
}
|
|
35
|
-
}
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof AuthError) throw err
|
|
54
|
+
if (mandatory) throw new AuthError('JWT verification failed')
|
|
36
55
|
return null
|
|
37
56
|
}
|
|
38
57
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { parse as parseTOML } from 'smol-toml'
|
|
3
|
-
import type { ProxyConfig } from './types.js'
|
|
3
|
+
import type { AgentConfig, MultiAgentProxyConfig, ProxyConfig } from './types.js'
|
|
4
4
|
|
|
5
5
|
export function loadConfig(path: string): ProxyConfig {
|
|
6
6
|
const raw = readFileSync(path, 'utf-8')
|
|
@@ -8,7 +8,8 @@ export function loadConfig(path: string): ProxyConfig {
|
|
|
8
8
|
let parsed: Record<string, unknown>
|
|
9
9
|
if (path.endsWith('.json')) {
|
|
10
10
|
parsed = JSON.parse(raw)
|
|
11
|
-
}
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
12
13
|
parsed = parseTOML(raw) as Record<string, unknown>
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -26,3 +27,58 @@ export function loadConfig(path: string): ProxyConfig {
|
|
|
26
27
|
grant_required: (parsed.grant_required ?? []) as ProxyConfig['grant_required'],
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load config as multi-agent format.
|
|
33
|
+
* If the config has an `agents` array, use it directly.
|
|
34
|
+
* Otherwise, convert single-agent format to multi-agent for backward-compat.
|
|
35
|
+
*/
|
|
36
|
+
export function loadMultiAgentConfig(path: string, overrides?: { mandatoryAuth?: boolean }): MultiAgentProxyConfig {
|
|
37
|
+
const raw = readFileSync(path, 'utf-8')
|
|
38
|
+
|
|
39
|
+
let parsed: Record<string, unknown>
|
|
40
|
+
if (path.endsWith('.json')) {
|
|
41
|
+
parsed = JSON.parse(raw)
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
parsed = parseTOML(raw) as Record<string, unknown>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const proxy = parsed.proxy as Record<string, unknown>
|
|
48
|
+
if (!proxy?.listen) {
|
|
49
|
+
throw new Error('Config must have [proxy] with listen')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const baseProxy: MultiAgentProxyConfig['proxy'] = {
|
|
53
|
+
listen: proxy.listen as string,
|
|
54
|
+
default_action: (proxy.default_action as MultiAgentProxyConfig['proxy']['default_action']) ?? 'block',
|
|
55
|
+
audit_log: proxy.audit_log as string | undefined,
|
|
56
|
+
mandatory_auth: overrides?.mandatoryAuth ?? (proxy.mandatory_auth as boolean | undefined),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Multi-agent format: has agents array
|
|
60
|
+
if (Array.isArray(parsed.agents)) {
|
|
61
|
+
return {
|
|
62
|
+
proxy: baseProxy,
|
|
63
|
+
agents: parsed.agents as AgentConfig[],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Single-agent format: convert to multi-agent
|
|
68
|
+
const idpUrl = proxy.idp_url as string
|
|
69
|
+
const agentEmail = proxy.agent_email as string
|
|
70
|
+
if (!idpUrl || !agentEmail) {
|
|
71
|
+
throw new Error('Single-agent config requires proxy.idp_url and proxy.agent_email')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
proxy: baseProxy,
|
|
76
|
+
agents: [{
|
|
77
|
+
email: agentEmail,
|
|
78
|
+
idp_url: idpUrl,
|
|
79
|
+
allow: (parsed.allow ?? []) as AgentConfig['allow'],
|
|
80
|
+
deny: (parsed.deny ?? []) as AgentConfig['deny'],
|
|
81
|
+
grant_required: (parsed.grant_required ?? []) as AgentConfig['grant_required'],
|
|
82
|
+
}],
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import { connect } from 'node:net'
|
|
4
|
+
import type { MultiAgentProxyConfig } from './types.js'
|
|
5
|
+
import { AuthError, verifyAgentAuth } from './auth.js'
|
|
6
|
+
import { isPrivateOrLoopback } from './ssrf.js'
|
|
7
|
+
import { writeAudit } from './audit.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle HTTP CONNECT requests for tunneling (used by HTTP_PROXY clients).
|
|
11
|
+
* Flow: Auth check → SSRF check → TCP connect → bidirectional pipe.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleConnect(
|
|
14
|
+
config: MultiAgentProxyConfig,
|
|
15
|
+
req: IncomingMessage,
|
|
16
|
+
clientSocket: Socket,
|
|
17
|
+
_head: Buffer,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const target = req.url ?? ''
|
|
20
|
+
const [host, portStr] = target.split(':')
|
|
21
|
+
const port = Number.parseInt(portStr || '443')
|
|
22
|
+
|
|
23
|
+
if (!host || !port) {
|
|
24
|
+
clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
|
|
25
|
+
clientSocket.destroy()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mandatoryAuth = config.proxy.mandatory_auth ?? false
|
|
30
|
+
|
|
31
|
+
// Auth check — CONNECT always requires auth in mandatory mode
|
|
32
|
+
let agentEmail: string | undefined
|
|
33
|
+
try {
|
|
34
|
+
const authHeader = req.headers['proxy-authorization'] as string | undefined
|
|
35
|
+
let identity: { email: string, act: 'agent' } | null = null
|
|
36
|
+
|
|
37
|
+
for (const agentConf of config.agents) {
|
|
38
|
+
identity = await verifyAgentAuth(
|
|
39
|
+
authHeader ?? null,
|
|
40
|
+
agentConf.idp_url,
|
|
41
|
+
mandatoryAuth && config.agents.length === 1,
|
|
42
|
+
)
|
|
43
|
+
if (identity) break
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (mandatoryAuth && !identity) {
|
|
47
|
+
throw new AuthError('JWT required')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
agentEmail = identity?.email
|
|
51
|
+
|
|
52
|
+
// Verify agent is known
|
|
53
|
+
if (agentEmail) {
|
|
54
|
+
const known = config.agents.find(a => a.email === agentEmail)
|
|
55
|
+
if (!known) {
|
|
56
|
+
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
|
|
57
|
+
clientSocket.destroy()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (config.agents.length > 1) {
|
|
62
|
+
throw new AuthError('JWT required for multi-agent proxy')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof AuthError) {
|
|
67
|
+
clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
|
68
|
+
clientSocket.destroy()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// SSRF check
|
|
75
|
+
if (await isPrivateOrLoopback(host)) {
|
|
76
|
+
writeAudit({
|
|
77
|
+
ts: new Date().toISOString(),
|
|
78
|
+
agent: agentEmail ?? config.agents[0]?.email ?? 'unknown',
|
|
79
|
+
action: 'deny',
|
|
80
|
+
domain: host,
|
|
81
|
+
method: 'CONNECT',
|
|
82
|
+
path: target,
|
|
83
|
+
rule: 'ssrf-blocked',
|
|
84
|
+
})
|
|
85
|
+
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
|
|
86
|
+
clientSocket.destroy()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Connect to target
|
|
91
|
+
const targetSocket = connect(port, host, () => {
|
|
92
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
93
|
+
|
|
94
|
+
// Bidirectional pipe
|
|
95
|
+
targetSocket.pipe(clientSocket)
|
|
96
|
+
clientSocket.pipe(targetSocket)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
targetSocket.on('error', () => {
|
|
100
|
+
clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
|
|
101
|
+
clientSocket.destroy()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
clientSocket.on('error', () => {
|
|
105
|
+
targetSocket.destroy()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Cleanup on close
|
|
109
|
+
clientSocket.on('close', () => targetSocket.destroy())
|
|
110
|
+
targetSocket.on('close', () => clientSocket.destroy())
|
|
111
|
+
}
|
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,9 +1,13 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
1
3
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import type {
|
|
4
|
+
import type { AgentConfig, AuditEntry, MultiAgentProxyConfig, ProxyConfig } from './types.js'
|
|
3
5
|
import { evaluateRules } from './matcher.js'
|
|
4
|
-
import { verifyAgentAuth } from './auth.js'
|
|
6
|
+
import { AuthError, verifyAgentAuth } from './auth.js'
|
|
5
7
|
import { GrantsClient } from './grants-client.js'
|
|
6
8
|
import { writeAudit } from './audit.js'
|
|
9
|
+
import { isPrivateOrLoopback } from './ssrf.js'
|
|
10
|
+
import { handleConnect } from './connect.js'
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Compute a request hash that uniquely identifies the intent.
|
|
@@ -19,11 +23,37 @@ async function computeRequestHash(method: string, targetUrl: string, body: Array
|
|
|
19
23
|
return hash.digest('hex')
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/** Legacy single-agent proxy */
|
|
22
27
|
export function createProxy(config: ProxyConfig) {
|
|
23
|
-
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
|
|
24
54
|
|
|
25
55
|
return {
|
|
26
|
-
port: parseInt(config.proxy.listen.split(':')[1] || '9090'),
|
|
56
|
+
port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
|
|
27
57
|
hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
|
|
28
58
|
|
|
29
59
|
async fetch(req: Request): Promise<Response> {
|
|
@@ -32,17 +62,19 @@ export function createProxy(config: ProxyConfig) {
|
|
|
32
62
|
|
|
33
63
|
// Health endpoint
|
|
34
64
|
if (url.pathname === '/healthz') {
|
|
35
|
-
return Response.json({
|
|
65
|
+
return Response.json({
|
|
66
|
+
status: 'ok',
|
|
67
|
+
agents: config.agents.map(a => a.email),
|
|
68
|
+
})
|
|
36
69
|
}
|
|
37
70
|
|
|
38
|
-
// Parse target URL from the path
|
|
39
|
-
// The agent sends: http://proxy:9090/https://api.github.com/repos/x/issues
|
|
40
|
-
// So the target URL is everything after the first /
|
|
71
|
+
// Parse target URL from the path
|
|
41
72
|
const targetUrl = url.pathname.slice(1) + url.search
|
|
42
73
|
let targetParsed: URL
|
|
43
74
|
try {
|
|
44
75
|
targetParsed = new URL(targetUrl)
|
|
45
|
-
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
46
78
|
return new Response(
|
|
47
79
|
'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
|
|
48
80
|
{ status: 400 },
|
|
@@ -53,23 +85,80 @@ export function createProxy(config: ProxyConfig) {
|
|
|
53
85
|
const method = req.method
|
|
54
86
|
const path = targetParsed.pathname
|
|
55
87
|
|
|
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
|
+
|
|
56
93
|
// Read body once (needed for hash + forwarding)
|
|
57
94
|
const bodyBuffer = req.body ? await req.arrayBuffer() : null
|
|
58
95
|
|
|
59
|
-
// Verify agent identity
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
139
|
|
|
65
|
-
const
|
|
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
|
+
}
|
|
66
155
|
|
|
67
156
|
// Evaluate rules
|
|
68
|
-
const action = evaluateRules(
|
|
157
|
+
const action = evaluateRules(rulesConfig, domain, method, path)
|
|
69
158
|
|
|
70
159
|
const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
|
|
71
160
|
ts: new Date().toISOString(),
|
|
72
|
-
agent:
|
|
161
|
+
agent: effectiveEmail,
|
|
73
162
|
domain,
|
|
74
163
|
method,
|
|
75
164
|
path,
|
|
@@ -96,7 +185,7 @@ export function createProxy(config: ProxyConfig) {
|
|
|
96
185
|
|
|
97
186
|
// Check for existing grant
|
|
98
187
|
const existing = await grantsClient.findExistingGrant(
|
|
99
|
-
|
|
188
|
+
effectiveEmail,
|
|
100
189
|
domain,
|
|
101
190
|
permissions,
|
|
102
191
|
).catch(() => null)
|
|
@@ -121,7 +210,7 @@ export function createProxy(config: ProxyConfig) {
|
|
|
121
210
|
if (config.proxy.default_action === 'request-async') {
|
|
122
211
|
// Create grant request, return 407 immediately
|
|
123
212
|
const grant = await grantsClient.requestGrant({
|
|
124
|
-
requester:
|
|
213
|
+
requester: effectiveEmail,
|
|
125
214
|
target: domain,
|
|
126
215
|
grantType: rule.grant_type,
|
|
127
216
|
permissions,
|
|
@@ -152,7 +241,7 @@ export function createProxy(config: ProxyConfig) {
|
|
|
152
241
|
|
|
153
242
|
try {
|
|
154
243
|
const grant = await grantsClient.requestGrant({
|
|
155
|
-
requester:
|
|
244
|
+
requester: effectiveEmail,
|
|
156
245
|
target: domain,
|
|
157
246
|
grantType: rule.grant_type,
|
|
158
247
|
permissions,
|
|
@@ -185,8 +274,8 @@ export function createProxy(config: ProxyConfig) {
|
|
|
185
274
|
waited_ms: waitedMs,
|
|
186
275
|
})
|
|
187
276
|
return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
|
|
188
|
-
|
|
189
|
-
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
190
279
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
191
280
|
writeAudit({
|
|
192
281
|
...baseAudit,
|
|
@@ -200,6 +289,73 @@ export function createProxy(config: ProxyConfig) {
|
|
|
200
289
|
}
|
|
201
290
|
}
|
|
202
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
|
+
|
|
203
359
|
/**
|
|
204
360
|
* Forward a request to the target URL.
|
|
205
361
|
* Strips proxy-specific headers, preserves the rest.
|
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[]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AuthError, verifyAgentAuth } from '../src/auth.js'
|
|
3
|
+
|
|
4
|
+
describe('verifyAgentAuth', () => {
|
|
5
|
+
const idpUrl = 'https://id.example.com'
|
|
6
|
+
|
|
7
|
+
describe('non-mandatory mode', () => {
|
|
8
|
+
it('returns null when no header provided', async () => {
|
|
9
|
+
const result = await verifyAgentAuth(null, idpUrl, false)
|
|
10
|
+
expect(result).toBeNull()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns null for invalid header format', async () => {
|
|
14
|
+
const result = await verifyAgentAuth('Basic abc123', idpUrl, false)
|
|
15
|
+
expect(result).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for invalid JWT', async () => {
|
|
19
|
+
const result = await verifyAgentAuth('Bearer invalid.token.here', idpUrl, false)
|
|
20
|
+
expect(result).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('mandatory mode', () => {
|
|
25
|
+
it('throws AuthError when no header provided', async () => {
|
|
26
|
+
await expect(
|
|
27
|
+
verifyAgentAuth(null, idpUrl, true),
|
|
28
|
+
).rejects.toThrow(AuthError)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('throws AuthError with "JWT required" message', async () => {
|
|
32
|
+
await expect(
|
|
33
|
+
verifyAgentAuth(null, idpUrl, true),
|
|
34
|
+
).rejects.toThrow('JWT required')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('throws AuthError for invalid header format', async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
verifyAgentAuth('Basic abc123', idpUrl, true),
|
|
40
|
+
).rejects.toThrow(AuthError)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('throws AuthError for invalid JWT token', async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
verifyAgentAuth('Bearer invalid.token.here', idpUrl, true),
|
|
46
|
+
).rejects.toThrow(AuthError)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('authError', () => {
|
|
51
|
+
it('is an instance of Error', () => {
|
|
52
|
+
const err = new AuthError('test')
|
|
53
|
+
expect(err).toBeInstanceOf(Error)
|
|
54
|
+
expect(err.name).toBe('AuthError')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { connect } from 'node:net'
|
|
3
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
4
|
+
import type { MultiAgentProxyConfig } from '../src/types.js'
|
|
5
|
+
import { handleConnect } from '../src/connect.js'
|
|
6
|
+
|
|
7
|
+
function makeConfig(overrides?: Partial<MultiAgentProxyConfig['proxy']>): MultiAgentProxyConfig {
|
|
8
|
+
return {
|
|
9
|
+
proxy: {
|
|
10
|
+
listen: '127.0.0.1:0',
|
|
11
|
+
default_action: 'block',
|
|
12
|
+
mandatory_auth: true,
|
|
13
|
+
...overrides,
|
|
14
|
+
},
|
|
15
|
+
agents: [{
|
|
16
|
+
email: 'bot@example.com',
|
|
17
|
+
idp_url: 'https://id.example.com',
|
|
18
|
+
}],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function startProxy(config: MultiAgentProxyConfig): Promise<{ port: number, close: () => Promise<void> }> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const server = createServer((_req, res) => {
|
|
25
|
+
res.writeHead(200)
|
|
26
|
+
res.end('ok')
|
|
27
|
+
})
|
|
28
|
+
server.on('connect', (req, socket, head) => {
|
|
29
|
+
handleConnect(config, req, socket, head)
|
|
30
|
+
})
|
|
31
|
+
server.listen(0, '127.0.0.1', () => {
|
|
32
|
+
const addr = server.address() as { port: number }
|
|
33
|
+
resolve({
|
|
34
|
+
port: addr.port,
|
|
35
|
+
close: () => new Promise<void>(r => server.close(() => r())),
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sendConnect(proxyPort: number, target: string, headers?: Record<string, string>): Promise<{ statusLine: string }> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const socket = connect(proxyPort, '127.0.0.1', () => {
|
|
44
|
+
let headerStr = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n`
|
|
45
|
+
if (headers) {
|
|
46
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
47
|
+
headerStr += `${k}: ${v}\r\n`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
headerStr += '\r\n'
|
|
51
|
+
socket.write(headerStr)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
let data = ''
|
|
55
|
+
socket.on('data', (chunk) => {
|
|
56
|
+
data += chunk.toString()
|
|
57
|
+
if (data.includes('\r\n\r\n')) {
|
|
58
|
+
const statusLine = data.split('\r\n')[0]
|
|
59
|
+
socket.destroy()
|
|
60
|
+
resolve({ statusLine })
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
socket.on('error', reject)
|
|
65
|
+
socket.setTimeout(3000, () => {
|
|
66
|
+
socket.destroy()
|
|
67
|
+
reject(new Error('Timeout'))
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('connect handler', () => {
|
|
73
|
+
let cleanup: (() => Promise<void>) | undefined
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
if (cleanup) {
|
|
77
|
+
await cleanup()
|
|
78
|
+
cleanup = undefined
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('blocks CONNECT to loopback IP (SSRF)', async () => {
|
|
83
|
+
const config = makeConfig({ mandatory_auth: false })
|
|
84
|
+
config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
|
|
85
|
+
const { port, close } = await startProxy(config)
|
|
86
|
+
cleanup = close
|
|
87
|
+
|
|
88
|
+
const { statusLine } = await sendConnect(port, '127.0.0.1:3000')
|
|
89
|
+
expect(statusLine).toContain('403')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('blocks CONNECT to private IP (SSRF)', async () => {
|
|
93
|
+
const config = makeConfig({ mandatory_auth: false })
|
|
94
|
+
config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
|
|
95
|
+
const { port, close } = await startProxy(config)
|
|
96
|
+
cleanup = close
|
|
97
|
+
|
|
98
|
+
const { statusLine } = await sendConnect(port, '10.0.0.1:80')
|
|
99
|
+
expect(statusLine).toContain('403')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns 401 without JWT when mandatory auth is enabled', async () => {
|
|
103
|
+
const config = makeConfig({ mandatory_auth: true })
|
|
104
|
+
const { port, close } = await startProxy(config)
|
|
105
|
+
cleanup = close
|
|
106
|
+
|
|
107
|
+
const { statusLine } = await sendConnect(port, 'httpbin.org:443')
|
|
108
|
+
expect(statusLine).toContain('401')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns 401 with invalid JWT when mandatory auth is enabled', async () => {
|
|
112
|
+
const config = makeConfig({ mandatory_auth: true })
|
|
113
|
+
const { port, close } = await startProxy(config)
|
|
114
|
+
cleanup = close
|
|
115
|
+
|
|
116
|
+
const { statusLine } = await sendConnect(port, 'httpbin.org:443', {
|
|
117
|
+
'Proxy-Authorization': 'Bearer invalid.token.here',
|
|
118
|
+
})
|
|
119
|
+
expect(statusLine).toContain('401')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('blocks malformed target (treated as SSRF)', async () => {
|
|
123
|
+
const config = makeConfig({ mandatory_auth: false })
|
|
124
|
+
config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
|
|
125
|
+
const { port, close } = await startProxy(config)
|
|
126
|
+
cleanup = close
|
|
127
|
+
|
|
128
|
+
const { statusLine } = await sendConnect(port, 'not-a-host')
|
|
129
|
+
expect(statusLine).toContain('403')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { loadMultiAgentConfig } from '../src/config.js'
|
|
6
|
+
|
|
7
|
+
function tmpFile(name: string, content: string): string {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'proxy-test-'))
|
|
9
|
+
const path = join(dir, name)
|
|
10
|
+
writeFileSync(path, content)
|
|
11
|
+
return path
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('loadMultiAgentConfig', () => {
|
|
15
|
+
it('loads multi-agent TOML config', () => {
|
|
16
|
+
const path = tmpFile('proxy.toml', `
|
|
17
|
+
[proxy]
|
|
18
|
+
listen = "127.0.0.1:9090"
|
|
19
|
+
default_action = "block"
|
|
20
|
+
mandatory_auth = true
|
|
21
|
+
|
|
22
|
+
[[agents]]
|
|
23
|
+
email = "bot1@example.com"
|
|
24
|
+
idp_url = "https://id1.example.com"
|
|
25
|
+
|
|
26
|
+
[[agents.allow]]
|
|
27
|
+
domain = "api.github.com"
|
|
28
|
+
|
|
29
|
+
[[agents]]
|
|
30
|
+
email = "bot2@example.com"
|
|
31
|
+
idp_url = "https://id2.example.com"
|
|
32
|
+
`)
|
|
33
|
+
|
|
34
|
+
const config = loadMultiAgentConfig(path)
|
|
35
|
+
|
|
36
|
+
expect(config.proxy.listen).toBe('127.0.0.1:9090')
|
|
37
|
+
expect(config.proxy.default_action).toBe('block')
|
|
38
|
+
expect(config.proxy.mandatory_auth).toBe(true)
|
|
39
|
+
expect(config.agents).toHaveLength(2)
|
|
40
|
+
expect(config.agents[0].email).toBe('bot1@example.com')
|
|
41
|
+
expect(config.agents[0].idp_url).toBe('https://id1.example.com')
|
|
42
|
+
expect(config.agents[0].allow).toHaveLength(1)
|
|
43
|
+
expect(config.agents[0].allow![0].domain).toBe('api.github.com')
|
|
44
|
+
expect(config.agents[1].email).toBe('bot2@example.com')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('converts single-agent config to multi-agent format', () => {
|
|
48
|
+
const path = tmpFile('legacy.toml', `
|
|
49
|
+
[proxy]
|
|
50
|
+
listen = "127.0.0.1:9090"
|
|
51
|
+
idp_url = "https://id.example.com"
|
|
52
|
+
agent_email = "agent@example.com"
|
|
53
|
+
default_action = "request-async"
|
|
54
|
+
|
|
55
|
+
[[allow]]
|
|
56
|
+
domain = "*.github.com"
|
|
57
|
+
|
|
58
|
+
[[deny]]
|
|
59
|
+
domain = "evil.com"
|
|
60
|
+
|
|
61
|
+
[[grant_required]]
|
|
62
|
+
domain = "api.openai.com"
|
|
63
|
+
grant_type = "once"
|
|
64
|
+
`)
|
|
65
|
+
|
|
66
|
+
const config = loadMultiAgentConfig(path)
|
|
67
|
+
|
|
68
|
+
expect(config.proxy.listen).toBe('127.0.0.1:9090')
|
|
69
|
+
expect(config.proxy.default_action).toBe('request-async')
|
|
70
|
+
expect(config.agents).toHaveLength(1)
|
|
71
|
+
expect(config.agents[0].email).toBe('agent@example.com')
|
|
72
|
+
expect(config.agents[0].idp_url).toBe('https://id.example.com')
|
|
73
|
+
expect(config.agents[0].allow).toHaveLength(1)
|
|
74
|
+
expect(config.agents[0].deny).toHaveLength(1)
|
|
75
|
+
expect(config.agents[0].grant_required).toHaveLength(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('applies mandatory-auth override', () => {
|
|
79
|
+
const path = tmpFile('override.toml', `
|
|
80
|
+
[proxy]
|
|
81
|
+
listen = "127.0.0.1:9090"
|
|
82
|
+
idp_url = "https://id.example.com"
|
|
83
|
+
agent_email = "agent@example.com"
|
|
84
|
+
`)
|
|
85
|
+
|
|
86
|
+
const config = loadMultiAgentConfig(path, { mandatoryAuth: true })
|
|
87
|
+
expect(config.proxy.mandatory_auth).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('loads multi-agent JSON config', () => {
|
|
91
|
+
const path = tmpFile('proxy.json', JSON.stringify({
|
|
92
|
+
proxy: {
|
|
93
|
+
listen: '127.0.0.1:9090',
|
|
94
|
+
default_action: 'block',
|
|
95
|
+
},
|
|
96
|
+
agents: [
|
|
97
|
+
{ email: 'a@b.com', idp_url: 'https://id.example.com' },
|
|
98
|
+
],
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
const config = loadMultiAgentConfig(path)
|
|
102
|
+
expect(config.agents).toHaveLength(1)
|
|
103
|
+
expect(config.agents[0].email).toBe('a@b.com')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('throws on missing listen', () => {
|
|
107
|
+
const path = tmpFile('bad.toml', `
|
|
108
|
+
[proxy]
|
|
109
|
+
default_action = "block"
|
|
110
|
+
`)
|
|
111
|
+
expect(() => loadMultiAgentConfig(path)).toThrow('listen')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('throws on single-agent without idp_url', () => {
|
|
115
|
+
const path = tmpFile('bad2.toml', `
|
|
116
|
+
[proxy]
|
|
117
|
+
listen = "127.0.0.1:9090"
|
|
118
|
+
agent_email = "agent@example.com"
|
|
119
|
+
`)
|
|
120
|
+
expect(() => loadMultiAgentConfig(path)).toThrow('idp_url')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isPrivateOrLoopback } from '../src/ssrf.js'
|
|
3
|
+
|
|
4
|
+
describe('isPrivateOrLoopback', () => {
|
|
5
|
+
// IPv4 loopback
|
|
6
|
+
it('blocks 127.0.0.1', async () => {
|
|
7
|
+
expect(await isPrivateOrLoopback('127.0.0.1')).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('blocks 127.255.255.255', async () => {
|
|
11
|
+
expect(await isPrivateOrLoopback('127.255.255.255')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// RFC 1918
|
|
15
|
+
it('blocks 10.0.0.1', async () => {
|
|
16
|
+
expect(await isPrivateOrLoopback('10.0.0.1')).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('blocks 172.16.0.1', async () => {
|
|
20
|
+
expect(await isPrivateOrLoopback('172.16.0.1')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('blocks 172.31.255.255', async () => {
|
|
24
|
+
expect(await isPrivateOrLoopback('172.31.255.255')).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('blocks 192.168.1.1', async () => {
|
|
28
|
+
expect(await isPrivateOrLoopback('192.168.1.1')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Link-local
|
|
32
|
+
it('blocks 169.254.0.1', async () => {
|
|
33
|
+
expect(await isPrivateOrLoopback('169.254.0.1')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Unspecified
|
|
37
|
+
it('blocks 0.0.0.0', async () => {
|
|
38
|
+
expect(await isPrivateOrLoopback('0.0.0.0')).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// IPv6
|
|
42
|
+
it('blocks ::1', async () => {
|
|
43
|
+
expect(await isPrivateOrLoopback('::1')).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('blocks ::', async () => {
|
|
47
|
+
expect(await isPrivateOrLoopback('::')).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// localhost
|
|
51
|
+
it('blocks localhost', async () => {
|
|
52
|
+
expect(await isPrivateOrLoopback('localhost')).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Public IPs — should NOT be blocked
|
|
56
|
+
it('allows 8.8.8.8', async () => {
|
|
57
|
+
expect(await isPrivateOrLoopback('8.8.8.8')).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('allows 1.1.1.1', async () => {
|
|
61
|
+
expect(await isPrivateOrLoopback('1.1.1.1')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// 172.15.x.x is NOT in 172.16.0.0/12
|
|
65
|
+
it('allows 172.15.255.255', async () => {
|
|
66
|
+
expect(await isPrivateOrLoopback('172.15.255.255')).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// 172.32.x.x is NOT in 172.16.0.0/12
|
|
70
|
+
it('allows 172.32.0.1', async () => {
|
|
71
|
+
expect(await isPrivateOrLoopback('172.32.0.1')).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|