chore: prepare GoodBuddy 0.8.5
This commit is contained in:
@@ -1,15 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setIntranetCompatibilityReader } from '../intranet-compatibility-policy'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteDelegationService } from './remote-delegation-service'
|
||||
|
||||
beforeEach(() => {
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
})
|
||||
|
||||
describe('RemoteDelegationService', () => {
|
||||
it('polls a public HTTPS endpoint and posts a bounded result', async () => {
|
||||
const transport = vi
|
||||
@@ -172,23 +163,24 @@ describe('RemoteDelegationService', () => {
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects endpoints resolving to private networks', async () => {
|
||||
it('allows endpoints resolving to private networks', async () => {
|
||||
const transport = vi.fn(async () => ({ status: 204, body: '' }))
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||
transport: vi.fn(),
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow('私有或不安全网络')
|
||||
await expect(service.pollOnce()).resolves.toBeUndefined()
|
||||
expect(transport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows pinned HTTP private endpoints in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
it('allows pinned HTTP private endpoints and preserves path prefixes', async () => {
|
||||
const transport = vi.fn(async () => ({ status: 204, body: '' }))
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
endpoint: 'http://delegate.internal/reverse-proxy',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
|
||||
transport,
|
||||
@@ -200,7 +192,7 @@ describe('RemoteDelegationService', () => {
|
||||
expect(transport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
protocol: 'http:',
|
||||
pathname: '/goodbuddy/tasks/next'
|
||||
pathname: '/reverse-proxy/goodbuddy/tasks/next'
|
||||
}),
|
||||
{ address: '10.20.30.40', family: 4 },
|
||||
'test-token',
|
||||
@@ -209,9 +201,8 @@ describe('RemoteDelegationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('requires HTTPS for public endpoints even in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const transport = vi.fn()
|
||||
it('allows public HTTP endpoints', async () => {
|
||||
const transport = vi.fn(async () => ({ status: 204, body: '' }))
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.example',
|
||||
token: 'test-token',
|
||||
@@ -220,31 +211,38 @@ describe('RemoteDelegationService', () => {
|
||||
onTask: vi.fn()
|
||||
})
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow(
|
||||
'HTTP 远程委派仅允许解析到内网地址'
|
||||
)
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
await expect(service.pollOnce()).resolves.toBeUndefined()
|
||||
expect(transport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unsafe endpoints and mixed DNS answers blocked in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
expect(
|
||||
() =>
|
||||
new RemoteDelegationService({
|
||||
endpoint: 'http://metadata.google.internal',
|
||||
token: 'test-token',
|
||||
onTask: vi.fn()
|
||||
})
|
||||
).toThrow('元数据')
|
||||
expect(
|
||||
() =>
|
||||
new RemoteDelegationService({
|
||||
endpoint: 'http://user:secret@delegate.internal',
|
||||
token: 'test-token',
|
||||
onTask: vi.fn()
|
||||
})
|
||||
).toThrow('无凭据')
|
||||
it('allows metadata names, credentials and mixed DNS answers', async () => {
|
||||
const metadataTransport = vi.fn(async () => ({
|
||||
status: 204,
|
||||
body: ''
|
||||
}))
|
||||
const metadata = new RemoteDelegationService({
|
||||
endpoint: 'http://metadata.google.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '169.254.169.254', family: 4 }],
|
||||
transport: metadataTransport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
await expect(metadata.pollOnce()).resolves.toBeUndefined()
|
||||
|
||||
const credentialTransport = vi.fn(async () => ({
|
||||
status: 204,
|
||||
body: ''
|
||||
}))
|
||||
const credentials = new RemoteDelegationService({
|
||||
endpoint: 'http://user:password@delegate.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
|
||||
transport: credentialTransport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
await expect(credentials.pollOnce()).resolves.toBeUndefined()
|
||||
|
||||
const mixedTransport = vi.fn(async () => ({ status: 204, body: '' }))
|
||||
const mixed = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
token: 'test-token',
|
||||
@@ -252,25 +250,10 @@ describe('RemoteDelegationService', () => {
|
||||
{ address: '10.20.30.40', family: 4 },
|
||||
{ address: '1.1.1.1', family: 4 }
|
||||
],
|
||||
transport: vi.fn(),
|
||||
transport: mixedTransport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
await expect(mixed.pollOnce()).rejects.toThrow('不安全网络')
|
||||
})
|
||||
|
||||
it('re-applies strict transport policy after compatibility mode is disabled', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const transport = vi.fn()
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow('HTTPS')
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
await expect(mixed.pollOnce()).resolves.toBeUndefined()
|
||||
expect(mixedTransport).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import { isIP } from 'node:net'
|
||||
import { z } from 'zod'
|
||||
import { isIntranetCompatibilityEnabled } from '../intranet-compatibility-policy'
|
||||
import {
|
||||
isIntranetAddress,
|
||||
isPublicAddress
|
||||
} from '../knowledge/url-importer'
|
||||
|
||||
const remoteTaskSchema = z
|
||||
.object({
|
||||
@@ -58,43 +52,27 @@ type RemoteDelegationOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKED_REMOTE_HOSTS = new Set([
|
||||
'instance-data',
|
||||
'instance-data.ec2.internal',
|
||||
'metadata',
|
||||
'metadata.aws.internal',
|
||||
'metadata.google.internal'
|
||||
])
|
||||
|
||||
function normalizeEndpoint(input: string): URL {
|
||||
const url = new URL(input.trim())
|
||||
if (
|
||||
(
|
||||
url.protocol !== 'https:' &&
|
||||
(
|
||||
url.protocol !== 'http:' ||
|
||||
!isIntranetCompatibilityEnabled()
|
||||
)
|
||||
) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
(url.pathname !== '' && url.pathname !== '/')
|
||||
) {
|
||||
throw new Error(
|
||||
isIntranetCompatibilityEnabled()
|
||||
? '远程委派地址必须是无凭据和路径的 HTTP(S) origin'
|
||||
: '远程委派地址必须是无凭据和路径的 HTTPS origin'
|
||||
)
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.$/u, '')
|
||||
if (BLOCKED_REMOTE_HOSTS.has(hostname)) {
|
||||
throw new Error('远程委派地址不允许访问云元数据服务')
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('远程委派地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
url.hash = ''
|
||||
url.pathname = url.pathname.replace(/\/+$/u, '')
|
||||
return url
|
||||
}
|
||||
|
||||
/** Keeps any reverse-proxy path prefix carried by the configured endpoint. */
|
||||
function endpointUrl(endpoint: URL, path: string): URL {
|
||||
const target = new URL(endpoint.toString())
|
||||
const prefix =
|
||||
endpoint.pathname === '/'
|
||||
? ''
|
||||
: endpoint.pathname.replace(/\/+$/u, '')
|
||||
target.pathname = `${prefix}${path}`
|
||||
return target
|
||||
}
|
||||
|
||||
async function defaultLookup(hostname: string): Promise<ResolvedAddress[]> {
|
||||
return dnsLookup(hostname, { all: true, verbatim: true })
|
||||
}
|
||||
@@ -232,7 +210,7 @@ export class RemoteDelegationService {
|
||||
)
|
||||
this.markDelivered(pending[0])
|
||||
}
|
||||
const nextUrl = new URL('/goodbuddy/tasks/next', this.endpoint)
|
||||
const nextUrl = endpointUrl(this.endpoint, '/goodbuddy/tasks/next')
|
||||
const response = await this.transport(
|
||||
nextUrl,
|
||||
address,
|
||||
@@ -292,9 +270,9 @@ export class RemoteDelegationService {
|
||||
address: ResolvedAddress,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const resultUrl = new URL(
|
||||
`/goodbuddy/tasks/${encodeURIComponent(taskId)}/result`,
|
||||
this.endpoint
|
||||
const resultUrl = endpointUrl(
|
||||
this.endpoint,
|
||||
`/goodbuddy/tasks/${encodeURIComponent(taskId)}/result`
|
||||
)
|
||||
const response = await this.transport(
|
||||
resultUrl,
|
||||
@@ -326,45 +304,9 @@ export class RemoteDelegationService {
|
||||
}
|
||||
|
||||
private async resolveAddress(): Promise<ResolvedAddress> {
|
||||
if (
|
||||
this.endpoint.protocol === 'http:' &&
|
||||
!isIntranetCompatibilityEnabled()
|
||||
) {
|
||||
throw new Error('远程委派地址必须使用 HTTPS')
|
||||
}
|
||||
const addresses = await this.lookup(this.endpoint.hostname)
|
||||
const addressTypes = addresses.map((candidate) =>
|
||||
candidate.family !== isIP(candidate.address)
|
||||
? 'blocked'
|
||||
: isPublicAddress(candidate.address)
|
||||
? 'public'
|
||||
: isIntranetAddress(candidate.address)
|
||||
? 'intranet'
|
||||
: 'blocked'
|
||||
)
|
||||
const address = addresses[0]
|
||||
const compatibilityEnabled = isIntranetCompatibilityEnabled()
|
||||
const plaintextOutsideIntranet =
|
||||
this.endpoint.protocol === 'http:' &&
|
||||
addressTypes.some((addressType) => addressType !== 'intranet')
|
||||
if (
|
||||
!address ||
|
||||
addressTypes.includes('blocked') ||
|
||||
new Set(addressTypes).size !== 1 ||
|
||||
plaintextOutsideIntranet ||
|
||||
(
|
||||
!compatibilityEnabled &&
|
||||
addressTypes.some((addressType) => addressType !== 'public')
|
||||
)
|
||||
) {
|
||||
if (
|
||||
plaintextOutsideIntranet &&
|
||||
!addressTypes.includes('blocked') &&
|
||||
new Set(addressTypes).size === 1
|
||||
) {
|
||||
throw new Error('HTTP 远程委派仅允许解析到内网地址')
|
||||
}
|
||||
throw new Error('远程委派地址解析到私有或不安全网络')
|
||||
const address = (await this.lookup(this.endpoint.hostname))[0]
|
||||
if (!address) {
|
||||
throw new Error('远程委派地址无法解析到任何 IP')
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user