@openape/proxy 0.2.14 → 0.3.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/dist/index.cjs +833 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +832 -0
- package/dist/index.js.map +1 -0
- package/package.json +7 -2
- package/.nvmrc +0 -1
- package/CHANGELOG.md +0 -140
- package/PLAN.md +0 -261
- package/bun.lock +0 -229
- package/config.toml +0 -35
- package/src/audit.ts +0 -20
- package/src/auth.ts +0 -57
- package/src/config.ts +0 -84
- package/src/connect.ts +0 -111
- package/src/grants-client.ts +0 -129
- package/src/index.ts +0 -69
- package/src/matcher.ts +0 -80
- package/src/proxy.ts +0 -401
- package/src/ssrf.ts +0 -85
- package/src/types.ts +0 -65
- package/test/auth.test.ts +0 -57
- package/test/connect.test.ts +0 -131
- package/test/matcher.test.ts +0 -46
- package/test/multi-agent.test.ts +0 -122
- package/test/ssrf.test.ts +0 -73
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -9
- package/vitest.config.ts +0 -18
package/src/ssrf.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/** Single-agent proxy configuration (legacy format, parsed from TOML/JSON) */
|
|
2
|
-
export interface ProxyConfig {
|
|
3
|
-
proxy: {
|
|
4
|
-
listen: string
|
|
5
|
-
idp_url: string
|
|
6
|
-
agent_email: string
|
|
7
|
-
default_action: 'block' | 'request' | 'request-async'
|
|
8
|
-
audit_log?: string
|
|
9
|
-
mandatory_auth?: boolean
|
|
10
|
-
}
|
|
11
|
-
allow: RuleEntry[]
|
|
12
|
-
deny: RuleEntry[]
|
|
13
|
-
grant_required: GrantRuleEntry[]
|
|
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
|
-
|
|
35
|
-
export interface RuleEntry {
|
|
36
|
-
domain: string
|
|
37
|
-
methods?: string[]
|
|
38
|
-
path?: string
|
|
39
|
-
note?: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface GrantRuleEntry extends RuleEntry {
|
|
43
|
-
grant_type: 'once' | 'timed' | 'always'
|
|
44
|
-
permissions?: string[]
|
|
45
|
-
duration?: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type RuleAction =
|
|
49
|
-
| { type: 'allow' }
|
|
50
|
-
| { type: 'deny'; note?: string }
|
|
51
|
-
| { type: 'grant_required'; rule: GrantRuleEntry }
|
|
52
|
-
|
|
53
|
-
export interface AuditEntry {
|
|
54
|
-
ts: string
|
|
55
|
-
agent: string
|
|
56
|
-
action: 'allow' | 'deny' | 'grant_approved' | 'grant_denied' | 'grant_timeout' | 'error'
|
|
57
|
-
domain: string
|
|
58
|
-
method: string
|
|
59
|
-
path: string
|
|
60
|
-
grant_id?: string | null
|
|
61
|
-
request_hash?: string
|
|
62
|
-
rule: string
|
|
63
|
-
waited_ms?: number
|
|
64
|
-
error?: string
|
|
65
|
-
}
|
package/test/auth.test.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
})
|
package/test/connect.test.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
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
|
-
})
|
package/test/matcher.test.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { evaluateRules } from '../src/matcher.js'
|
|
3
|
-
import type { ProxyConfig } from '../src/types.js'
|
|
4
|
-
|
|
5
|
-
const baseConfig: ProxyConfig = {
|
|
6
|
-
proxy: {
|
|
7
|
-
listen: '127.0.0.1:9090',
|
|
8
|
-
idp_url: 'https://id.example.com',
|
|
9
|
-
agent_email: 'agent@example.com',
|
|
10
|
-
default_action: 'block',
|
|
11
|
-
},
|
|
12
|
-
allow: [],
|
|
13
|
-
deny: [],
|
|
14
|
-
grant_required: [],
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('evaluateRules', () => {
|
|
18
|
-
it('prefers deny over allow', () => {
|
|
19
|
-
const action = evaluateRules(
|
|
20
|
-
{
|
|
21
|
-
...baseConfig,
|
|
22
|
-
deny: [{ domain: 'api.example.com' }],
|
|
23
|
-
allow: [{ domain: 'api.example.com' }],
|
|
24
|
-
},
|
|
25
|
-
'api.example.com',
|
|
26
|
-
'GET',
|
|
27
|
-
'/v1',
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
expect(action.type).toBe('deny')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('returns grant_required when matching grant rule', () => {
|
|
34
|
-
const action = evaluateRules(
|
|
35
|
-
{
|
|
36
|
-
...baseConfig,
|
|
37
|
-
grant_required: [{ domain: 'api.example.com', grant_type: 'once' }],
|
|
38
|
-
},
|
|
39
|
-
'api.example.com',
|
|
40
|
-
'POST',
|
|
41
|
-
'/v1',
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
expect(action.type).toBe('grant_required')
|
|
45
|
-
})
|
|
46
|
-
})
|
package/test/multi-agent.test.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
})
|
package/test/ssrf.test.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
})
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"forceConsistentCasingInFileNames": true,
|
|
11
|
-
"resolveJsonModule": true,
|
|
12
|
-
"isolatedModules": true,
|
|
13
|
-
"declaration": true,
|
|
14
|
-
"declarationMap": true,
|
|
15
|
-
"sourceMap": true,
|
|
16
|
-
"outDir": "dist",
|
|
17
|
-
"rootDir": "src"
|
|
18
|
-
},
|
|
19
|
-
"include": ["src"],
|
|
20
|
-
"exclude": ["node_modules", "dist"]
|
|
21
|
-
}
|
package/tsup.config.ts
DELETED
package/vitest.config.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config'
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
environment: 'node',
|
|
6
|
-
coverage: {
|
|
7
|
-
provider: 'istanbul',
|
|
8
|
-
include: ['src/**/*.ts'],
|
|
9
|
-
exclude: ['src/**/*.test.ts', 'src/**/index.ts', 'src/types/**'],
|
|
10
|
-
reporter: ['text', 'lcov'],
|
|
11
|
-
thresholds: {
|
|
12
|
-
statements: 29,
|
|
13
|
-
functions: 29,
|
|
14
|
-
lines: 29,
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
})
|