@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.
@@ -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,46 @@
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
+ })
@@ -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
+ })
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ },
7
+ })