@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
|
@@ -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
|
+
})
|