From 68f685d89b98df6c7101c27150a5d585bc6af2dc Mon Sep 17 00:00:00 2001 From: youfak Date: Wed, 29 Jul 2026 11:12:21 +0800 Subject: [PATCH] feat: add shared HTTP security protection --- api/openapi/admin.yaml | 2 + api/openapi/proxy-pool.yaml | 6 +- deploy/config/local.yaml | 29 +- deploy/kubernetes/base/configmap.yaml | 35 +- deploy/tools/configcheck/main.go | 2 +- docs/api/admin.md | 6 +- docs/api/distribution.md | 13 +- docs/configuration/reference.md | 16 +- docs/design/architecture.md | 6 +- docs/development/implementation-plan.md | 4 +- docs/requirements/completion-audit.md | 4 +- docs/security/security-model.md | 5 + internal/config/config_test.go | 108 ++++++ internal/config/validate.go | 31 ++ internal/controller/admin/handler.go | 20 +- internal/controller/admin/handler_test.go | 80 ++++- internal/controller/distribution/handler.go | 32 +- .../controller/distribution/handler_test.go | 60 ++++ internal/gateway/server/bootstrap.go | 53 +-- internal/gateway/server/bootstrap_test.go | 57 +++ internal/gateway/server/handler.go | 29 +- internal/gateway/server/protection.go | 221 +----------- internal/gateway/server/protection_test.go | 26 -- internal/platform/httpsecurity/auth.go | 242 +++++++++++++ internal/platform/httpsecurity/config.go | 54 +++ internal/platform/httpsecurity/config_test.go | 61 ++++ internal/platform/httpsecurity/protection.go | 122 +++++++ internal/platform/httpsecurity/response.go | 36 ++ .../platform/httpsecurity/response_test.go | 50 +++ .../platform/httpsecurity/security_test.go | 329 ++++++++++++++++++ internal/platform/httpsecurity/source.go | 167 +++++++++ internal/platform/httpsecurity/types.go | 86 +++++ 32 files changed, 1636 insertions(+), 356 deletions(-) create mode 100644 internal/platform/httpsecurity/auth.go create mode 100644 internal/platform/httpsecurity/config.go create mode 100644 internal/platform/httpsecurity/config_test.go create mode 100644 internal/platform/httpsecurity/protection.go create mode 100644 internal/platform/httpsecurity/response.go create mode 100644 internal/platform/httpsecurity/response_test.go create mode 100644 internal/platform/httpsecurity/security_test.go create mode 100644 internal/platform/httpsecurity/source.go create mode 100644 internal/platform/httpsecurity/types.go diff --git a/api/openapi/admin.yaml b/api/openapi/admin.yaml index b03c2e5..425fa1a 100644 --- a/api/openapi/admin.yaml +++ b/api/openapi/admin.yaml @@ -14,6 +14,7 @@ security: - AdminApiKey: [] - BasicAuth: [] - BearerAuth: [] + - {} paths: /api/v1/status: get: @@ -217,6 +218,7 @@ components: description: 管理入口认证失败 headers: X-Request-ID: {$ref: '#/components/headers/RequestID'} + WWW-Authenticate: {schema: {type: string}} content: application/problem+json: schema: {$ref: '#/components/schemas/Problem'} diff --git a/api/openapi/proxy-pool.yaml b/api/openapi/proxy-pool.yaml index cdbddbe..3cd66ee 100644 --- a/api/openapi/proxy-pool.yaml +++ b/api/openapi/proxy-pool.yaml @@ -155,7 +155,8 @@ components: required: false description: | 同一客户端在幂等记录保留期内重用该键会得到首次提交结果,不会再次 - 提取。建议所有会自动重试的客户端提供。 + 提取。未认证客户端默认以可信代理链解析后的规范化来源 IP 标识。 + 建议所有会自动重试的客户端提供。 schema: type: string minLength: 8 @@ -336,6 +337,9 @@ components: headers: X-Request-ID: $ref: '#/components/headers/RequestID' + WWW-Authenticate: + schema: + type: string content: application/problem+json: schema: diff --git a/deploy/config/local.yaml b/deploy/config/local.yaml index da97f0d..d83b855 100644 --- a/deploy/config/local.yaml +++ b/deploy/config/local.yaml @@ -12,7 +12,7 @@ gateway: auth: mode: usernamePassword username: local-gateway - password: env:PROXY_POOL_GATEWAY_PASSWORD + password: "${PROXY_POOL_GATEWAY_PASSWORD}" limits: maxConcurrentConnections: 20000 requestsPerMinutePerClient: 60000 @@ -34,12 +34,12 @@ distribution: auth: mode: apiKey header: X-API-Key - token: env:PROXY_POOL_EXTRACT_TOKEN + token: "${PROXY_POOL_EXTRACT_TOKEN}" limits: requestsPerMinute: 6000 requestsPerMinutePerClient: 600 clientIdentification: - mode: trustedProxyOrRemoteIP + mode: sourceIP extraction: fulfillment: partial maxCountPerRequest: 100 @@ -54,8 +54,8 @@ admin: allowCIDRs: [172.16.0.0/12] auth: mode: apiKey - header: X-Admin-Token - token: env:PROXY_POOL_ADMIN_TOKEN + header: X-Admin-Key + token: "${PROXY_POOL_ADMIN_TOKEN}" metrics: enabled: true @@ -98,12 +98,13 @@ upstreams: url: https://provider-a.invalid/api/proxies method: GET auth: - mode: apiKey - header: Authorization - token: env:PROVIDER_A_TOKEN + type: apiKey + location: header + name: Authorization + value: "${PROVIDER_A_TOKEN}" template: '{{ . }}' proxyAuth: - mode: response + type: response pool: maxSize: 5000 shrinkDelay: 30s @@ -142,12 +143,13 @@ upstreams: url: https://provider-b.invalid/api/proxies method: GET auth: - mode: apiKey - header: Authorization - token: env:PROVIDER_B_TOKEN + type: apiKey + location: header + name: Authorization + value: "${PROVIDER_B_TOKEN}" template: '{{ . }}' proxyAuth: - mode: response + type: response pool: maxSize: 5000 shrinkDelay: 30s @@ -176,4 +178,3 @@ upstreams: maxAttempts: 2 maxConsecutiveFailures: 3 urls: [https://example.com/] - diff --git a/deploy/kubernetes/base/configmap.yaml b/deploy/kubernetes/base/configmap.yaml index a472e27..d017e3f 100644 --- a/deploy/kubernetes/base/configmap.yaml +++ b/deploy/kubernetes/base/configmap.yaml @@ -16,8 +16,8 @@ data: trustedProxies: [] auth: mode: usernamePassword - username: env:PROXY_POOL_GATEWAY_USERNAME - password: env:PROXY_POOL_GATEWAY_PASSWORD + username: "${PROXY_POOL_GATEWAY_USERNAME}" + password: "${PROXY_POOL_GATEWAY_PASSWORD}" limits: maxConcurrentConnections: 100000 requestsPerMinutePerClient: 60000 @@ -38,12 +38,12 @@ data: auth: mode: apiKey header: X-API-Key - token: env:PROXY_POOL_EXTRACT_TOKEN + token: "${PROXY_POOL_EXTRACT_TOKEN}" limits: requestsPerMinute: 30000 requestsPerMinutePerClient: 3000 clientIdentification: - mode: trustedProxyOrRemoteIP + mode: sourceIP extraction: fulfillment: partial maxCountPerRequest: 100 @@ -57,14 +57,14 @@ data: allowCIDRs: [10.0.0.0/8] auth: mode: apiKey - header: X-Admin-Token - token: env:PROXY_POOL_ADMIN_TOKEN + header: X-Admin-Key + token: "${PROXY_POOL_ADMIN_TOKEN}" metrics: enabled: true listen: 0.0.0.0:9090 storage: - postgresURL: env:PROXY_POOL_POSTGRES_URL - redisURL: env:PROXY_POOL_REDIS_URL + postgresURL: "${PROXY_POOL_POSTGRES_URL}" + redisURL: "${PROXY_POOL_REDIS_URL}" routing: - name: gateway-default enabled: true @@ -97,12 +97,13 @@ data: url: https://PROVIDER_A_HOST/api/proxies method: GET auth: - mode: apiKey - header: Authorization - token: env:PROVIDER_A_TOKEN + type: apiKey + location: header + name: Authorization + value: "${PROVIDER_A_TOKEN}" template: '{{ . }}' proxyAuth: - mode: response + type: response pool: maxSize: 25000 shrinkDelay: 30s @@ -138,12 +139,13 @@ data: url: https://PROVIDER_B_HOST/api/proxies method: GET auth: - mode: apiKey - header: Authorization - token: env:PROVIDER_B_TOKEN + type: apiKey + location: header + name: Authorization + value: "${PROVIDER_B_TOKEN}" template: '{{ . }}' proxyAuth: - mode: response + type: response pool: maxSize: 25000 shrinkDelay: 30s @@ -169,4 +171,3 @@ data: maxAttempts: 2 maxConsecutiveFailures: 3 urls: [https://example.com/] - diff --git a/deploy/tools/configcheck/main.go b/deploy/tools/configcheck/main.go index 1585282..0d310cf 100644 --- a/deploy/tools/configcheck/main.go +++ b/deploy/tools/configcheck/main.go @@ -21,7 +21,7 @@ func main() { } defer file.Close() - if _, err := config.Load(file); err != nil { + if _, err := config.LoadResolved(file, config.OSResolver{}); err != nil { fmt.Fprintf(os.Stderr, "invalid config: %v\n", err) os.Exit(1) } diff --git a/docs/api/admin.md b/docs/api/admin.md index 376416a..eaa69ef 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -25,9 +25,9 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml` 不能混用配置格式版本或单 Worker Snapshot 版本。 严格 JSON、请求体上限、Request ID、JSON/Problem 响应由 -`platform/httpapi` 公用实现提供。Admin Handler 必须部署在独立监听器,并由 -外层认证与授权中间件保护;网关使用的 `Proxy-Authorization`/407 语义不得复用 -到 Admin 的 `Authorization`/401 语义。 +`platform/httpapi` 公用实现提供。Admin Handler 必须注入 `Authorizer`,标准 +装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。网关使用的 +`Proxy-Authorization`/407 语义不得复用到 Admin 的 `Authorization`/401 语义。 除契约中的 401/403/404/409/422 外,运行时还明确返回: diff --git a/docs/api/distribution.md b/docs/api/distribution.md index d2b3b63..9c3ab0e 100644 --- a/docs/api/distribution.md +++ b/docs/api/distribution.md @@ -199,9 +199,9 @@ Extraction Record 必须与状态更新处在相同事务边界或由同一权 proxyId, clientId, sourceIP, requestId, upstream, extractedAt, expiresAt ``` -无认证时 `clientId` 使用 `anonymous` 或稳定匿名标识并保留 `sourceIP`。记录只 -用于审计、排错和计费事实,不承担资源归还语义。Proxy 到期后可以清理运行 -记录,但 Extraction Record 按审计保留策略归档。 +无认证时 `clientId` 使用可信代理链解析后的规范化来源 IP 稳定标识,并保留 +`sourceIP`。记录只用于审计、排错和计费事实,不承担资源归还语义。Proxy +到期后可以清理运行记录,但 Extraction Record 按审计保留策略归档。 ## 11. 运行时实现边界 @@ -209,6 +209,7 @@ proxyId, clientId, sourceIP, requestId, upstream, extractedAt, expiresAt 身份结果注入、错误映射和健康探针。独占提取、TTL、Gateway 预留及幂等事务 继续由 `extraction.Service` 和持久化 Store 承担。 -请求体解码、Request ID 与 Problem JSON 统一复用 `platform/httpapi`。身份解析 -通过 `IdentityResolver` 注入;进程装配必须在 Handler 外层完成认证、可信代理 -来源解析与权限控制,且解析结果至少包含稳定 Client ID 或 Source IP。 +请求体解码、Request ID 与 Problem JSON 统一复用 `platform/httpapi`。认证、 +可信代理、来源控制、Client ID 和准入限流由必需的 `IdentityResolver` 注入, +标准装配使用 `httpsecurity.Protection`;解析结果至少包含稳定 Client ID 或 +Source IP,且安全检查先于请求体解析。 diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 655d021..aa60450 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -90,11 +90,14 @@ limits: - `none`:无身份认证,访问控制与限流仍生效。 - `usernamePassword`:使用 `username` 和 `password`。 - `apiKey`:使用 `header` 和 `token`。 +- `bearer`:使用 `Authorization: Bearer TOKEN`;Gateway 对应 + `Proxy-Authorization: Bearer TOKEN`。 - `ipWhitelist`:使用 `cidrs`。 -- `any`:`methods` 中任一方法成功即可;方法字段名仍是 `mode`。 +- `any`:`methods` 中任一方法成功即可;Bearer 方法的 Secret 使用 + `value`/`valueFile`。 -Gateway、Distribution 与 Provider API 的认证是三套独立边界。改变其中一套 -不得连带改变另外两套。 +Gateway、Distribution、Admin 与 Provider API 是独立认证边界。改变其中一套 +不得连带改变其他入口。 ## 4. Gateway @@ -157,6 +160,13 @@ Extraction 是固定的一次性独占行为,**没有** `mode`、`leaseDuratio `partial` 会提交实际可得数量;`allOrNothing` 数量不足时事务回滚,一个也不 提取。认证关闭时仍应使用 `sourceIP` 识别匿名 Client 并执行全局/来源限流。 +`clientIdentification.mode` 支持: + +- `sourceIP`:使用可信代理链解析后的来源地址;省略配置时采用此模式。 +- `authenticatedClient`:使用 Basic 用户名或 Token 的稳定不可逆摘要;要求 + 启用认证。 +- `authenticatedClientOrSourceIP`:优先认证主体,无主体时回退来源地址。 + ## 6. Routing ```yaml diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 0993b0e..c71bac2 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -388,7 +388,7 @@ flowchart LR ## 15. 安全 - Gateway、Distribution、Admin 认证相互独立。 -- `auth.mode` 支持 none、usernamePassword、apiKey、ipWhitelist 与组合 any。 +- `auth.mode` 支持 none、usernamePassword、apiKey、bearer、ipWhitelist 与组合 any。 - `access.allowCIDRs` 独立于认证;代理头只在来源属于 trustedProxies 时接受。 - 严格模式下,非回环监听且 auth=none、allowCIDRs 为空时启动失败。 - 目的地址解析前后都拒绝 loopback、private、link-local、metadata 和配置禁区。 @@ -425,7 +425,8 @@ CPU、内存、网络、Go 版本、配置和上游响应模型下测得。 ### 17.2 热路径预算 -- Dispatch 无 I/O、无全局锁,100k Proxy Snapshot 下 p99 小于 100 微秒。 +- Dispatch 无 I/O、无全局锁;100k Proxy Snapshot 下的设计预算为 p99 小于 + 100 微秒,仍需分位数基准验证。 - 所有队列、buffer、重试和日志均有界。 - Listener、Client、Routing、Worker 和 Proxy 均有独立准入限制。 - 过载在路由/建连前快速拒绝,不允许请求堆积耗尽内存。 @@ -455,4 +456,3 @@ CPU、内存、网络、Go 版本、配置和上游响应模型下测得。 项目架构必须包含四个命令、领域模块、Gateway/Controller/Checker 模块、 存储/协议 Adapter、OpenAPI/Proto、配置样例、Compose/Kubernetes、监控、 迁移、测试 fixture、负载场景和开发文档。目录存在但没有契约或测试不算完成。 - diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index f7f1e48..1ecb9f2 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -166,7 +166,9 @@ test/{fixtures,integration,e2e,load}/ 当前进度(2026-07-29):已实现共享 `platform/httpapi`、Distribution extract/live/ready Handler 与 Admin status/enable/disable/switch/reload Handler; 定向契约测试已覆盖严格 JSON、Body 上限、Request ID、幂等 Header、DTO 映射、 -404/405 及业务错误映射。端点正式勾选仍等待独立监听器装配、认证/授权中间件、 +404/405 及业务错误映射。共享 `platform/httpsecurity` 已补齐 Basic/API Key/ +Bearer/CIDR、可信代理、Client ID、本地准入和 API 401/Gateway 407 差异,并作为 +Admin/Distribution 必需依赖。端点正式勾选仍等待独立监听器装配、 PostgreSQL/Redis Adapter 与 Compose 集成测试。 ## Task 11: Checker and Health Reducer diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index be9b501..5619d05 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -38,7 +38,7 @@ - `PROVIDER-*`:Provider HTTP Client、严格响应上限、模板解析安全边界、凭据 引用 Store 与 Reconciler Adapter 已实现。 - `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin - Handler 已实现;独立进程装配与认证授权仍在后续范围。 + Handler 已实现;共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入。 ## 2. 已执行验证 @@ -68,7 +68,7 @@ CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有 5. Redis Leader、速率限制、心跳与可重建协调适配器。 6. Worker ownership drain/ACK/过期回收和网络快照流。 7. Checker 调度、探测器和健康 reducer。 -8. Admin/Distribution 独立监听器装配、鉴权授权、分布式限流和审计查询。 +8. Admin/Distribution 独立监听器装配、细粒度授权、分布式限流和审计查询。 9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 ## 4. 容量结论 diff --git a/docs/security/security-model.md b/docs/security/security-model.md index 8218c63..f89ee91 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -13,6 +13,11 @@ - 认证关闭不代表匿名状态消失:仍按可信代理链解析来源并形成 Client ID。 - Access、Auth、Rate Limit 和 Client Identification 相互独立。 - Admin 使用独立凭据,不能复用普通 Gateway 或 Distribution 凭据。 +- `httpsecurity.Protection` 统一实现 Basic、API Key、Bearer、CIDR、可信代理链、 + Client ID 与入口准入;API 使用 401/`WWW-Authenticate`,Gateway 使用 + 407/`Proxy-Authenticate`。 +- Token Client ID 使用 SHA-256 的 128 位摘要前缀,不把 Token 本身写入领域、 + 日志或审计键。 ## 3. 目标地址策略 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e3e3f8..936aa87 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "go.yaml.in/yaml/v4" ) const validConfig = ` @@ -170,6 +172,76 @@ func TestShippedConfigurationsAreValid(t *testing.T) { } } +func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) { + resolver := fixtureResolver{environment: map[string]string{ + "PROXY_POOL_GATEWAY_USERNAME": "resolved-gateway-user", + "PROXY_POOL_GATEWAY_PASSWORD": "resolved-gateway-password", + "PROXY_POOL_EXTRACT_TOKEN": "resolved-extract-token", + "PROXY_POOL_ADMIN_TOKEN": "resolved-admin-token", + "PROXY_POOL_POSTGRES_URL": "postgres://resolved", + "PROXY_POOL_REDIS_URL": "redis://resolved", + "PROVIDER_A_TOKEN": "resolved-provider-a-token", + "PROVIDER_B_TOKEN": "resolved-provider-b-token", + }} + tests := []struct { + name string + path string + configMap bool + wantGatewayUser string + wantPostgresURL string + wantRedisURL string + }{ + { + name: "local", + path: filepath.Join("..", "..", "deploy", "config", "local.yaml"), + wantGatewayUser: "local-gateway", + wantPostgresURL: "postgres://proxy_pool:local-only-change-me@postgres:5432/proxy_pool?sslmode=disable", + wantRedisURL: "redis://redis:6379/0", + }, + { + name: "kubernetes", + path: filepath.Join("..", "..", "deploy", "kubernetes", "base", "configmap.yaml"), + configMap: true, + wantGatewayUser: "resolved-gateway-user", + wantPostgresURL: "postgres://resolved", + wantRedisURL: "redis://resolved", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + content, err := os.ReadFile(test.path) + if err != nil { + t.Fatalf("ReadFile(): %v", err) + } + configuration := string(content) + if test.configMap { + var manifest struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal(content, &manifest); err != nil { + t.Fatalf("Unmarshal(): %v", err) + } + configuration = manifest.Data["config.yaml"] + } + cfg, err := LoadResolved(strings.NewReader(configuration), resolver) + if err != nil { + t.Fatalf("LoadResolved(): %v", err) + } + if cfg.Gateway.Auth.Username != test.wantGatewayUser || + cfg.Gateway.Auth.Password != "resolved-gateway-password" || + cfg.Distribution.Auth.Token != "resolved-extract-token" || + cfg.Admin.Auth.Token != "resolved-admin-token" || + cfg.Storage.PostgresURL != test.wantPostgresURL || + cfg.Storage.RedisURL != test.wantRedisURL || + cfg.Upstreams["provider-a"].API.Auth.Value != "resolved-provider-a-token" || + cfg.Upstreams["provider-b"].API.Auth.Value != "resolved-provider-b-token" { + t.Fatalf("deployment values were not resolved: %+v", cfg.Redacted()) + } + }) + } +} + func TestLoadResolvedExpandsEnvironmentWithoutChangingTemplateVariables(t *testing.T) { configured := strings.Replace(validConfig, ` auth: type: none`, ` auth: @@ -230,6 +302,20 @@ func TestValidateRejectsUnsupportedListenerAuthMode(t *testing.T) { } } +func TestValidateAcceptsBearerListenerAuthentication(t *testing.T) { + cfg := mustLoadValidConfig(t) + cfg.Distribution.Auth = Auth{Mode: "bearer", Token: "resolved-token"} + cfg.Distribution.ClientIdentification.Mode = "authenticatedClient" + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(bearer) error = %v", err) + } + + cfg.Distribution.Auth = Auth{Mode: "any", Methods: []AuthMethod{{Mode: "bearer", Value: "method-token"}}} + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(any bearer) error = %v", err) + } +} + func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) { tests := []struct { name string @@ -283,6 +369,28 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) { }, want: "trustedProxies", }, + { + name: "negative listener request limit", + mutate: func(cfg *Config) { + cfg.Distribution.Limits.RequestsPerMinute = -1 + }, + want: "requestsPerMinute", + }, + { + name: "invalid client identification mode", + mutate: func(cfg *Config) { + cfg.Distribution.ClientIdentification.Mode = "header" + }, + want: "clientIdentification.mode", + }, + { + name: "authenticated client without authentication", + mutate: func(cfg *Config) { + cfg.Distribution.Auth = Auth{Mode: "none"} + cfg.Distribution.ClientIdentification.Mode = "authenticatedClient" + }, + want: "authenticatedClient requires authentication", + }, { name: "invalid destination deny CIDR", mutate: func(cfg *Config) { diff --git a/internal/config/validate.go b/internal/config/validate.go index 0384ef4..a5e22d7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -55,6 +55,17 @@ func Validate(cfg *Config) error { } } if cfg.Distribution.Enabled { + clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode + if clientIdentificationMode == "" { + clientIdentificationMode = "sourceIP" + } + if err := validateEnum("distribution.clientIdentification.mode", clientIdentificationMode, + "sourceIP", "authenticatedClient", "authenticatedClientOrSourceIP"); err != nil { + return err + } + if clientIdentificationMode == "authenticatedClient" && cfg.Distribution.Auth.Mode == "none" { + return fmt.Errorf("validate distribution clientIdentification.mode: authenticatedClient requires authentication") + } if err := requirePositive("distribution.maxCountPerRequest", cfg.Distribution.Extraction.MaxCountPerRequest); err != nil { return err } @@ -84,6 +95,18 @@ func validateListener(name string, listener Listener, security Security) error { if err := validateListenerAuth(name, listener.Auth); err != nil { return err } + for _, limit := range []struct { + name string + value int + }{ + {name: "maxConcurrentConnections", value: listener.Limits.MaxConcurrentConnections}, + {name: "requestsPerMinute", value: listener.Limits.RequestsPerMinute}, + {name: "requestsPerMinutePerClient", value: listener.Limits.RequestsPerMinutePerClient}, + } { + if limit.value < 0 { + return fmt.Errorf("validate %s limits.%s: must be non-negative", name, limit.name) + } + } host, _, err := net.SplitHostPort(listener.Listen) if err != nil { return fmt.Errorf("validate %s listen: %w", name, err) @@ -210,6 +233,10 @@ func validateListenerAuth(listener string, auth Auth) error { if auth.Header == "" || (auth.Token == "" && auth.TokenFile == "") { return fmt.Errorf("validate %s auth.mode apiKey: header and token are required", listener) } + case "bearer": + if auth.Token == "" && auth.TokenFile == "" { + return fmt.Errorf("validate %s auth.mode bearer: token is required", listener) + } case "ipWhitelist": if len(auth.CIDRs) == 0 { return fmt.Errorf("validate %s auth.mode ipWhitelist: cidrs are required", listener) @@ -242,6 +269,10 @@ func validateAuthMethod(listener string, index int, method AuthMethod) error { if method.Header == "" || (method.Value == "" && method.ValueFile == "") { return fmt.Errorf("validate %s auth.methods[%d]: header and value are required", listener, index) } + case "bearer": + if method.Value == "" && method.ValueFile == "" { + return fmt.Errorf("validate %s auth.methods[%d]: bearer value is required", listener, index) + } case "ipWhitelist": if len(method.CIDRs) == 0 { return fmt.Errorf("validate %s auth.methods[%d]: cidrs are required", listener, index) diff --git a/internal/controller/admin/handler.go b/internal/controller/admin/handler.go index d4187c6..ed5fe44 100644 --- a/internal/controller/admin/handler.go +++ b/internal/controller/admin/handler.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) const ( @@ -33,6 +34,12 @@ type Service interface { ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error) } +type Authorizer interface { + Check(context.Context, *http.Request) error +} + +var _ Authorizer = (*httpsecurity.Protection)(nil) + type Options struct { MaxBodyBytes int64 } @@ -91,14 +98,15 @@ type ReloadCommand struct { type Handler struct { service Service + authorizer Authorizer maxBodyBytes int64 } -func NewHandler(service Service, options Options) (*Handler, error) { - if service == nil || options.MaxBodyBytes <= 0 { +func NewHandler(service Service, authorizer Authorizer, options Options) (*Handler, error) { + if service == nil || authorizer == nil || options.MaxBodyBytes <= 0 { return nil, ErrInvalidHandler } - return &Handler{service: service, maxBodyBytes: options.MaxBodyBytes}, nil + return &Handler{service: service, authorizer: authorizer, maxBodyBytes: options.MaxBodyBytes}, nil } func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -107,6 +115,12 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ writeTransportProblem(writer, http.StatusBadRequest, "INVALID_REQUEST_ID", "Invalid request ID", "X-Request-ID is invalid", requestID) return } + if err := handler.authorizer.Check(request.Context(), request); err != nil { + if !httpsecurity.WriteProblem(writer, requestID, err) { + writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID) + } + return + } switch request.URL.Path { case statusPath: diff --git a/internal/controller/admin/handler_test.go b/internal/controller/admin/handler_test.go index c8ec4e7..cbef09f 100644 --- a/internal/controller/admin/handler_test.go +++ b/internal/controller/admin/handler_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) { @@ -39,12 +40,68 @@ func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) { func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) { t.Parallel() - if _, err := NewHandler(nil, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { + if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { t.Fatalf("NewHandler(nil) error = %v, want %v", err, ErrInvalidHandler) } - if _, err := NewHandler(&stubService{}, Options{}); !errors.Is(err, ErrInvalidHandler) { + if _, err := NewHandler(&stubService{}, allowAuthorizer{}, Options{}); !errors.Is(err, ErrInvalidHandler) { t.Fatalf("NewHandler(zero limit) error = %v, want %v", err, ErrInvalidHandler) } + if _, err := NewHandler(&stubService{}, nil, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { + t.Fatalf("NewHandler(nil authorizer) error = %v, want %v", err, ErrInvalidHandler) + } +} + +func TestHandlerAuthorizesBeforeRouting(t *testing.T) { + t.Parallel() + service := &stubService{} + handler, err := NewHandler(service, rejectAuthorizer{}, Options{MaxBodyBytes: 1024}) + if err != nil { + t.Fatalf("NewHandler() error = %v", err) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/missing", nil)) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusUnauthorized, recorder.Body.String()) + } + if recorder.Header().Get("WWW-Authenticate") != `Basic realm="proxy-pool"` { + t.Fatalf("challenge = %q", recorder.Header().Get("WWW-Authenticate")) + } + if service.statusCalls != 0 { + t.Fatalf("status calls = %d, want 0", service.statusCalls) + } +} + +func TestHandlerUsesHTTPProtectionAuthenticationContract(t *testing.T) { + t.Parallel() + service := &stubService{status: Status{ConfigVersion: "cfg-1"}} + protection, err := httpsecurity.New(httpsecurity.Config{ + Authentication: httpsecurity.Authentication{Mode: httpsecurity.ModeBearer, Token: "admin-token"}, + }, nil) + if err != nil { + t.Fatalf("httpsecurity.New() error = %v", err) + } + handler, err := NewHandler(service, protection, Options{MaxBodyBytes: 1024}) + if err != nil { + t.Fatalf("NewHandler() error = %v", err) + } + + unauthorized := httptest.NewRecorder() + handler.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)) + if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("WWW-Authenticate") != `Bearer realm="proxy-pool"` { + t.Fatalf("unauthorized response = status %d challenge %q", unauthorized.Code, unauthorized.Header().Get("WWW-Authenticate")) + } + if service.statusCalls != 0 { + t.Fatalf("status calls after rejection = %d, want 0", service.statusCalls) + } + + authorized := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + request.Header.Set("Authorization", "Bearer admin-token") + handler.ServeHTTP(authorized, request) + if authorized.Code != http.StatusOK || service.statusCalls != 1 { + t.Fatalf("authorized response = status %d calls %d", authorized.Code, service.statusCalls) + } } func TestHandlerEnablesAndDisablesUpstream(t *testing.T) { @@ -202,7 +259,7 @@ func TestHandlerRejectsInvalidTransportRequests(t *testing.T) { func mustHandler(t *testing.T, service Service) *Handler { t.Helper() - handler, err := NewHandler(service, Options{MaxBodyBytes: 1024}) + handler, err := NewHandler(service, allowAuthorizer{}, Options{MaxBodyBytes: 1024}) if err != nil { t.Fatalf("NewHandler() error = %v", err) } @@ -216,9 +273,11 @@ type stubService struct { lastUpstream SetUpstreamCommand lastSwitch SwitchCommand lastReload ReloadCommand + statusCalls int } func (service *stubService) Status(context.Context) (Status, error) { + service.statusCalls++ return service.status, service.err } @@ -236,3 +295,18 @@ func (service *stubService) ReloadConfiguration(_ context.Context, command Reloa service.lastReload = command return service.mutation, service.err } + +type allowAuthorizer struct{} + +func (allowAuthorizer) Check(context.Context, *http.Request) error { return nil } + +type rejectAuthorizer struct{} + +func (rejectAuthorizer) Check(context.Context, *http.Request) error { + return &httpsecurity.HTTPError{ + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + Header: http.Header{"WWW-Authenticate": []string{`Basic realm="proxy-pool"`}}, + Cause: errors.New("credential secret"), + } +} diff --git a/internal/controller/distribution/handler.go b/internal/controller/distribution/handler.go index bb4cf0f..d690b16 100644 --- a/internal/controller/distribution/handler.go +++ b/internal/controller/distribution/handler.go @@ -10,6 +10,7 @@ import ( controllerExtraction "github.com/proxy-pool/proxy-pool/internal/controller/extraction" domainExtraction "github.com/proxy-pool/proxy-pool/internal/domain/extraction" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) const ( @@ -38,15 +39,14 @@ type Extractor interface { Extract(context.Context, controllerExtraction.Request) (controllerExtraction.Response, error) } -type Identity struct { - ClientID string - SourceIP string -} +type Identity = httpsecurity.Identity type IdentityResolver interface { Resolve(*http.Request) (Identity, error) } +var _ IdentityResolver = (*httpsecurity.Protection)(nil) + type ReadinessChecker interface { Ready(context.Context) error } @@ -156,6 +156,24 @@ func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Request, requestID string) { + identity, err := h.identity.Resolve(request) + if err != nil { + if !httpsecurity.WriteProblem(writer, requestID, err) { + h.writeProblem(writer, httpapi.NewProblem( + http.StatusInternalServerError, + "INTERNAL_ERROR", + "Internal server error", + "", + requestID, + )) + } + return + } + if strings.TrimSpace(identity.ClientID) == "" && strings.TrimSpace(identity.SourceIP) == "" { + h.writeProblem(writer, problemBadRequest(requestID, "INVALID_REQUEST", "Invalid request", "", nil)) + return + } + idempotencyKey, err := validateIdempotencyKey(request.Header.Values(headerIdempotencyKey)) if err != nil { h.writeProblem(writer, problemBadRequest(requestID, "INVALID_HEADER", "Invalid request header", "", []httpapi.InvalidParam{{ @@ -183,12 +201,6 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques return } - identity, err := h.identity.Resolve(request) - if err != nil || (strings.TrimSpace(identity.ClientID) == "" && strings.TrimSpace(identity.SourceIP) == "") { - h.writeProblem(writer, problemBadRequest(requestID, "INVALID_REQUEST", "Invalid request", "", nil)) - return - } - filters := payload.filtersOrZero() serviceResponse, err := h.extractor.Extract(request.Context(), controllerExtraction.Request{ RequestID: requestID, diff --git a/internal/controller/distribution/handler_test.go b/internal/controller/distribution/handler_test.go index 22a60cf..0d027d3 100644 --- a/internal/controller/distribution/handler_test.go +++ b/internal/controller/distribution/handler_test.go @@ -14,6 +14,7 @@ import ( controllerExtraction "github.com/proxy-pool/proxy-pool/internal/controller/extraction" domainExtraction "github.com/proxy-pool/proxy-pool/internal/domain/extraction" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) func TestNewHandlerValidatesDependenciesAndBodyLimit(t *testing.T) { @@ -202,6 +203,65 @@ func TestHandlerRejectsDuplicateIdempotencyHeader(t *testing.T) { } } +func TestHandlerMapsSecurityFailureBeforeParsingBody(t *testing.T) { + t.Parallel() + extractor := &fakeExtractor{} + handler := mustNewHandler(t, Config{BodyLimitBytes: 1024}, Dependencies{ + Extractor: extractor, + Identity: fakeIdentityResolver{err: &httpsecurity.HTTPError{ + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + Header: http.Header{"WWW-Authenticate": []string{`Bearer realm="proxy-pool"`}}, + Cause: errors.New("token=secret"), + }}, + Readiness: fakeReadinessChecker{}, + }) + request := httptest.NewRequest(http.MethodPost, "/api/v1/proxies/extract", strings.NewReader(`not-json`)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusUnauthorized, response.Body.String()) + } + if challenge := response.Header().Get("WWW-Authenticate"); challenge != `Bearer realm="proxy-pool"` { + t.Fatalf("challenge = %q", challenge) + } + if strings.Contains(response.Body.String(), "secret") { + t.Fatalf("security response leaked cause: %s", response.Body.String()) + } + if extractor.calls != 0 { + t.Fatalf("extractor calls = %d, want 0", extractor.calls) + } +} + +func TestHandlerMapsUnexpectedIdentityFailureToInternalError(t *testing.T) { + t.Parallel() + extractor := &fakeExtractor{} + handler := mustNewHandler(t, Config{BodyLimitBytes: 1024}, Dependencies{ + Extractor: extractor, + Identity: fakeIdentityResolver{err: errors.New("credential store secret")}, + Readiness: fakeReadinessChecker{}, + }) + request := httptest.NewRequest(http.MethodPost, "/api/v1/proxies/extract", strings.NewReader(`{"count":1}`)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusInternalServerError, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"code":"INTERNAL_ERROR"`) { + t.Fatalf("body = %s, want INTERNAL_ERROR", response.Body.String()) + } + if strings.Contains(response.Body.String(), "secret") { + t.Fatalf("response leaked dependency error: %s", response.Body.String()) + } + if extractor.calls != 0 { + t.Fatalf("extractor calls = %d, want 0", extractor.calls) + } +} + func TestHandlerExtractMapsErrorsToProblemResponsesWithoutSensitiveLeakage(t *testing.T) { t.Parallel() tooManyRegions := `{"count":1,"filters":{"regions":["` + strings.Join(makeUniqueValues(65), `","`) + `"]}}` diff --git a/internal/gateway/server/bootstrap.go b/internal/gateway/server/bootstrap.go index 4643d43..23c426c 100644 --- a/internal/gateway/server/bootstrap.go +++ b/internal/gateway/server/bootstrap.go @@ -1,13 +1,13 @@ package server import ( - "errors" "fmt" "time" "github.com/proxy-pool/proxy-pool/internal/config" "github.com/proxy-pool/proxy-pool/internal/gateway/policy" platformAdmission "github.com/proxy-pool/proxy-pool/internal/platform/admission" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) type Protection struct { @@ -26,7 +26,7 @@ func BuildProtection(listener config.Listener) (Protection, error) { if err != nil { return Protection{}, err } - auth, err := buildConfiguredAuth(listener.Auth, clientIPs) + auth, err := buildConfiguredAuth(listener) if err != nil { return Protection{}, err } @@ -69,40 +69,25 @@ func explicitlyAllowed(deny *bool) bool { return deny != nil && !*deny } -func buildConfiguredAuth(auth config.Auth, clientIPs *ClientIPResolver) (Guard, error) { - switch auth.Mode { +func buildConfiguredAuth(listener config.Listener) (Guard, error) { + switch listener.Auth.Mode { case "", "none": return nil, nil - case "usernamePassword": - return NewBasicAuthGuard(auth.Username, auth.Password), nil - case "apiKey": - return NewAPIKeyGuard(auth.Header, auth.Token), nil - case "ipWhitelist": - return NewAccessGuard(clientIPs, auth.CIDRs) - case "any": - methods := make([]Guard, 0, len(auth.Methods)) - for index, method := range auth.Methods { - guard, err := buildConfiguredMethod(method, clientIPs) - if err != nil { - return nil, fmt.Errorf("build gateway auth method %d: %w", index, err) - } - methods = append(methods, guard) - } - return NewAnyGuard(methods...), nil - default: - return nil, fmt.Errorf("build gateway auth: unsupported mode %q", auth.Mode) } -} - -func buildConfiguredMethod(method config.AuthMethod, clientIPs *ClientIPResolver) (Guard, error) { - switch method.Mode { - case "usernamePassword": - return NewBasicAuthGuard(method.Username, method.Password), nil - case "apiKey": - return NewAPIKeyGuard(method.Header, method.Value), nil - case "ipWhitelist": - return NewAccessGuard(clientIPs, method.CIDRs) - default: - return nil, errors.New("unsupported authentication method: " + method.Mode) + authListener := config.Listener{ + Access: config.Access{ + TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...), + }, + Auth: listener.Auth, } + protection, err := httpsecurity.NewFromListener( + authListener, + httpsecurity.ClientSourceIP, + httpsecurity.ProxySemantics, + nil, + ) + if err != nil { + return nil, fmt.Errorf("build gateway authentication: %w", err) + } + return protection, nil } diff --git a/internal/gateway/server/bootstrap_test.go b/internal/gateway/server/bootstrap_test.go index c57824e..03dc786 100644 --- a/internal/gateway/server/bootstrap_test.go +++ b/internal/gateway/server/bootstrap_test.go @@ -9,6 +9,7 @@ import ( "github.com/proxy-pool/proxy-pool/internal/config" "github.com/proxy-pool/proxy-pool/internal/gateway/policy" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) func TestBuildProtectionFromListenerConfig(t *testing.T) { @@ -38,6 +39,62 @@ func TestBuildProtectionFromListenerConfig(t *testing.T) { } } +func TestBuildProtectionSupportsBearerProxyAuthentication(t *testing.T) { + t.Parallel() + protection, err := BuildProtection(config.Listener{Auth: config.Auth{Mode: "bearer", Token: "proxy-token"}}) + if err != nil { + t.Fatalf("BuildProtection() error = %v", err) + } + request := httptest.NewRequest(http.MethodGet, "http://example.test", nil) + request.RemoteAddr = "198.51.100.8:1234" + request.Header.Set("Proxy-Authorization", "Bearer proxy-token") + if err := protection.Auth.Check(context.Background(), request); err != nil { + t.Fatalf("auth.Check() error = %v", err) + } + + request.Header.Set("Proxy-Authorization", "Bearer wrong") + err = protection.Auth.Check(context.Background(), request) + var securityError *httpsecurity.HTTPError + if !errors.As(err, &securityError) || securityError.StatusCode != http.StatusProxyAuthRequired { + t.Fatalf("auth.Check(wrong) error = %T %v", err, err) + } + recorder := httptest.NewRecorder() + writeGatewayError(recorder, err) + if recorder.Code != http.StatusProxyAuthRequired || recorder.Header().Get("Proxy-Authenticate") == "" { + t.Fatalf("gateway response = status %d headers %v", recorder.Code, recorder.Header()) + } +} + +func TestBuildProtectionAnyPreservesIPWhitelistRejectionIndependentOfOrder(t *testing.T) { + t.Parallel() + methods := [][]config.AuthMethod{ + { + {Mode: "ipWhitelist", CIDRs: []string{"10.0.0.0/8"}}, + {Mode: "apiKey", Header: "X-Proxy-Key", Value: "secret"}, + }, + { + {Mode: "apiKey", Header: "X-Proxy-Key", Value: "secret"}, + {Mode: "ipWhitelist", CIDRs: []string{"10.0.0.0/8"}}, + }, + } + for index, configuredMethods := range methods { + protection, err := BuildProtection(config.Listener{ + Auth: config.Auth{Mode: "any", Methods: configuredMethods}, + }) + if err != nil { + t.Fatalf("BuildProtection(%d) error = %v", index, err) + } + request := httptest.NewRequest(http.MethodGet, "http://example.test", nil) + request.RemoteAddr = "198.51.100.8:1234" + err = protection.Auth.Check(context.Background(), request) + recorder := httptest.NewRecorder() + writeGatewayError(recorder, err) + if recorder.Code != http.StatusForbidden { + t.Fatalf("method order %d status = %d, want 403", index, recorder.Code) + } + } +} + func TestTargetPolicyFromOmittedConfigDefaultsToDeny(t *testing.T) { t.Parallel() diff --git a/internal/gateway/server/handler.go b/internal/gateway/server/handler.go index b36c72a..438d46d 100644 --- a/internal/gateway/server/handler.go +++ b/internal/gateway/server/handler.go @@ -18,6 +18,7 @@ import ( "github.com/proxy-pool/proxy-pool/internal/gateway/dispatch" "github.com/proxy-pool/proxy-pool/internal/gateway/policy" transportDomain "github.com/proxy-pool/proxy-pool/internal/gateway/transport" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) type Config struct { @@ -524,17 +525,23 @@ func writeGatewayError(writer http.ResponseWriter, err error) { status = httpError.StatusCode copyHeaders(writer.Header(), httpError.Header) } else { - switch { - case errors.Is(err, policy.ErrInvalidAuthority): - status = http.StatusBadRequest - case errors.Is(err, policy.ErrTargetDenied): - status = http.StatusForbidden - case errors.Is(err, ErrRouteRejected): - status = http.StatusForbidden - case errors.Is(err, dispatch.ErrNoCandidate), errors.Is(err, ErrRouteNotFound): - status = http.StatusServiceUnavailable - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - status = http.StatusGatewayTimeout + var securityError *httpsecurity.HTTPError + if errors.As(err, &securityError) { + status = securityError.StatusCode + copyHeaders(writer.Header(), securityError.Header) + } else { + switch { + case errors.Is(err, policy.ErrInvalidAuthority): + status = http.StatusBadRequest + case errors.Is(err, policy.ErrTargetDenied): + status = http.StatusForbidden + case errors.Is(err, ErrRouteRejected): + status = http.StatusForbidden + case errors.Is(err, dispatch.ErrNoCandidate), errors.Is(err, ErrRouteNotFound): + status = http.StatusServiceUnavailable + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + status = http.StatusGatewayTimeout + } } } http.Error(writer, http.StatusText(status), status) diff --git a/internal/gateway/server/protection.go b/internal/gateway/server/protection.go index 1870952..b688935 100644 --- a/internal/gateway/server/protection.go +++ b/internal/gateway/server/protection.go @@ -2,17 +2,12 @@ package server import ( "context" - "crypto/subtle" - "encoding/base64" "errors" "fmt" - "net" "net/http" - "net/netip" - "strconv" - "strings" "github.com/proxy-pool/proxy-pool/internal/gateway/policy" + "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) type HTTPError struct { @@ -30,190 +25,9 @@ func (err *HTTPError) Error() string { func (err *HTTPError) Unwrap() error { return err.Cause } -type BasicAuthGuard struct { - username string - password string -} +type ClientIPResolver = httpsecurity.ClientIPResolver -func NewBasicAuthGuard(username, password string) *BasicAuthGuard { - return &BasicAuthGuard{username: username, password: password} -} - -func (guard *BasicAuthGuard) Check(_ context.Context, request *http.Request) error { - username, password, ok := parseBasicCredentials(request.Header.Get("Proxy-Authorization")) - if ok && constantTimeEqual(username, guard.username) && constantTimeEqual(password, guard.password) { - return nil - } - return &HTTPError{ - StatusCode: http.StatusProxyAuthRequired, - Header: http.Header{"Proxy-Authenticate": []string{`Basic realm="proxy"`}}, - Cause: errors.New("proxy authentication failed"), - } -} - -type APIKeyGuard struct { - header string - value string -} - -func NewAPIKeyGuard(header, value string) *APIKeyGuard { - if strings.TrimSpace(header) == "" { - header = "X-API-Key" - } - return &APIKeyGuard{header: header, value: value} -} - -func (guard *APIKeyGuard) Check(_ context.Context, request *http.Request) error { - if constantTimeEqual(request.Header.Get(guard.header), guard.value) { - return nil - } - return &HTTPError{StatusCode: http.StatusProxyAuthRequired, Cause: errors.New("proxy API key authentication failed")} -} - -type AnyGuard struct { - guards []Guard -} - -func NewAnyGuard(guards ...Guard) *AnyGuard { - return &AnyGuard{guards: append([]Guard(nil), guards...)} -} - -func (guard *AnyGuard) Check(ctx context.Context, request *http.Request) error { - var lastErr error - for _, candidate := range guard.guards { - if candidate == nil { - continue - } - if err := candidate.Check(ctx, request); err == nil { - return nil - } else { - lastErr = err - } - } - if lastErr == nil { - lastErr = errors.New("no authentication method is configured") - } - return lastErr -} - -type ClientIPResolver struct { - trusted policy.CIDRMatcher -} - -func NewClientIPResolver(trustedCIDRs []string) (*ClientIPResolver, error) { - trusted, err := policy.NewCIDRMatcher(trustedCIDRs) - if err != nil { - return nil, fmt.Errorf("create client IP resolver: %w", err) - } - return &ClientIPResolver{trusted: trusted}, nil -} - -func (resolver *ClientIPResolver) Resolve(request *http.Request) (netip.Addr, error) { - if resolver == nil || request == nil { - return netip.Addr{}, errors.New("resolve client IP: resolver and request are required") - } - peer, err := parseRemoteAddress(request.RemoteAddr) - if err != nil { - return netip.Addr{}, err - } - if !resolver.trusted.Match(peer) { - return peer, nil - } - - chain, present, err := parseForwardedChain(request.Header.Values("Forwarded")) - if err != nil { - return netip.Addr{}, err - } - if !present { - chain, err = parseXForwardedFor(request.Header.Values("X-Forwarded-For")) - if err != nil { - return netip.Addr{}, err - } - } - if len(chain) == 0 { - return peer, nil - } - for index := len(chain) - 1; index >= 0; index-- { - if !resolver.trusted.Match(chain[index]) { - return chain[index], nil - } - } - if len(chain) > 0 { - return chain[0], nil - } - return peer, nil -} - -func parseXForwardedFor(fields []string) ([]netip.Addr, error) { - chain := make([]netip.Addr, 0, len(fields)+1) - for _, field := range fields { - for value := range strings.SplitSeq(field, ",") { - address, err := netip.ParseAddr(strings.TrimSpace(value)) - if err != nil { - return nil, fmt.Errorf("resolve client IP: invalid X-Forwarded-For address %q", value) - } - chain = append(chain, address.Unmap()) - } - } - return chain, nil -} - -func parseForwardedChain(fields []string) ([]netip.Addr, bool, error) { - if len(fields) == 0 { - return nil, false, nil - } - chain := make([]netip.Addr, 0, len(fields)+1) - for _, field := range fields { - for element := range strings.SplitSeq(field, ",") { - found := false - for parameter := range strings.SplitSeq(element, ";") { - name, value, ok := strings.Cut(strings.TrimSpace(parameter), "=") - if !ok || !strings.EqualFold(name, "for") { - continue - } - address, err := parseForwardedIdentifier(value) - if err != nil { - return nil, true, err - } - chain = append(chain, address) - found = true - break - } - if !found { - return nil, true, errors.New("resolve client IP: Forwarded element is missing for parameter") - } - } - } - return chain, true, nil -} - -func parseForwardedIdentifier(raw string) (netip.Addr, error) { - value := strings.TrimSpace(raw) - if strings.HasPrefix(value, `"`) { - unquoted, err := strconv.Unquote(value) - if err != nil { - return netip.Addr{}, fmt.Errorf("resolve client IP: invalid quoted Forwarded identifier") - } - value = unquoted - } - if strings.EqualFold(value, "unknown") || strings.HasPrefix(value, "_") { - return netip.Addr{}, fmt.Errorf("resolve client IP: non-IP Forwarded identifier") - } - if strings.HasPrefix(value, "[") { - closing := strings.IndexByte(value, ']') - if closing < 0 { - return netip.Addr{}, fmt.Errorf("resolve client IP: invalid Forwarded IPv6 identifier") - } - value = value[1:closing] - } else if host, _, err := net.SplitHostPort(value); err == nil { - value = host - } - address, err := netip.ParseAddr(value) - if err != nil { - return netip.Addr{}, fmt.Errorf("resolve client IP: invalid Forwarded address") - } - return address.Unmap(), nil -} +var NewClientIPResolver = httpsecurity.NewClientIPResolver type AccessGuard struct { resolver *ClientIPResolver @@ -269,32 +83,3 @@ func (guard *AdmissionGuard) Check(ctx context.Context, request *http.Request) e } return nil } - -func parseBasicCredentials(value string) (string, string, bool) { - scheme, encoded, ok := strings.Cut(strings.TrimSpace(value), " ") - if !ok || !strings.EqualFold(scheme, "Basic") { - return "", "", false - } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return "", "", false - } - username, password, ok := strings.Cut(string(decoded), ":") - return username, password, ok -} - -func constantTimeEqual(actual, expected string) bool { - return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 -} - -func parseRemoteAddress(remote string) (netip.Addr, error) { - host, _, err := net.SplitHostPort(strings.TrimSpace(remote)) - if err != nil { - host = strings.TrimSpace(remote) - } - address, err := netip.ParseAddr(host) - if err != nil { - return netip.Addr{}, fmt.Errorf("resolve client IP: invalid remote address %q", remote) - } - return address.Unmap(), nil -} diff --git a/internal/gateway/server/protection_test.go b/internal/gateway/server/protection_test.go index 04f694c..a772cb3 100644 --- a/internal/gateway/server/protection_test.go +++ b/internal/gateway/server/protection_test.go @@ -9,32 +9,6 @@ import ( "testing" ) -func TestBasicAuthGuardUsesProxyAuthorizationAndReturnsChallenge(t *testing.T) { - t.Parallel() - - guard := NewBasicAuthGuard("client", "secret") - allowed := httptest.NewRequest(http.MethodGet, "http://example.test", nil) - allowed.SetBasicAuth("ignored", "ignored") - allowed.Header.Set("Proxy-Authorization", "Basic Y2xpZW50OnNlY3JldA==") - if err := guard.Check(context.Background(), allowed); err != nil { - t.Fatalf("Check(valid) error = %v", err) - } - - denied := httptest.NewRequest(http.MethodGet, "http://example.test", nil) - denied.Header.Set("Proxy-Authorization", "Basic Y2xpZW50Ondyb25n") - err := guard.Check(context.Background(), denied) - var httpError *HTTPError - if !errors.As(err, &httpError) { - t.Fatalf("Check(invalid) error = %T %v, want *HTTPError", err, err) - } - if httpError.StatusCode != http.StatusProxyAuthRequired { - t.Fatalf("status = %d, want 407", httpError.StatusCode) - } - if got := httpError.Header.Get("Proxy-Authenticate"); got != `Basic realm="proxy"` { - t.Fatalf("challenge = %q", got) - } -} - func TestClientIPResolverOnlyTrustsForwardedChainFromTrustedPeer(t *testing.T) { t.Parallel() diff --git a/internal/platform/httpsecurity/auth.go b/internal/platform/httpsecurity/auth.go new file mode 100644 index 0000000..db6d8dd --- /dev/null +++ b/internal/platform/httpsecurity/auth.go @@ -0,0 +1,242 @@ +package httpsecurity + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "net/netip" + "strings" +) + +var ( + errCredentialRejected = errors.New("credential rejected") + errSourceRejected = errors.New("source rejected") +) + +type authenticator interface { + authenticate(*http.Request, string) (string, error) + challenges() []string +} + +type noAuthenticator struct{} + +func (noAuthenticator) authenticate(*http.Request, string) (string, error) { return "", nil } +func (noAuthenticator) challenges() []string { return nil } + +type basicAuthenticator struct { + header string + username string + password string +} + +func (auth basicAuthenticator) authenticate(request *http.Request, _ string) (string, error) { + value, ok := singleHeader(request, auth.header) + username, password, parsed := parseBasicCredentials(value) + valid := subtle.ConstantTimeSelect(boolInt(ok && parsed), 1, 0) + valid &= secureEqual(username, auth.username) + valid &= secureEqual(password, auth.password) + if valid != 1 { + return "", errCredentialRejected + } + return "basic:" + auth.username, nil +} + +func (basicAuthenticator) challenges() []string { return []string{`Basic realm="proxy-pool"`} } + +type tokenAuthenticator struct { + mode string + header string + token string +} + +func (auth tokenAuthenticator) authenticate(request *http.Request, _ string) (string, error) { + value, ok := singleHeader(request, auth.header) + if auth.mode == ModeBearer { + value, ok = parseScheme(value, "Bearer", ok) + } + if !ok || secureEqual(value, auth.token) != 1 { + return "", errCredentialRejected + } + return credentialSubject(auth.mode, auth.token), nil +} + +func (auth tokenAuthenticator) challenges() []string { + if auth.mode == ModeBearer { + return []string{`Bearer realm="proxy-pool"`} + } + return []string{`ApiKey realm="proxy-pool", header="` + auth.header + `"`} +} + +type ipAuthenticator struct{ allowed cidrMatcher } + +func (auth ipAuthenticator) authenticate(_ *http.Request, source string) (string, error) { + address, err := netip.ParseAddr(source) + if err != nil || !auth.allowed.match(address) { + return "", errSourceRejected + } + return "source:" + source, nil +} + +func (ipAuthenticator) challenges() []string { return nil } + +type anyAuthenticator struct{ methods []authenticator } + +func (auth anyAuthenticator) authenticate(request *http.Request, source string) (string, error) { + sourceRejected := false + for _, method := range auth.methods { + principal, err := method.authenticate(request, source) + if err == nil { + return principal, nil + } + if errors.Is(err, errSourceRejected) { + sourceRejected = true + } + } + if sourceRejected { + return "", errSourceRejected + } + return "", errCredentialRejected +} + +func (auth anyAuthenticator) challenges() []string { + var result []string + for _, method := range auth.methods { + result = append(result, method.challenges()...) + } + return result +} + +func buildAuthenticator(authentication Authentication, semantics Semantics) (authenticator, error) { + header := "Authorization" + if semantics == ProxySemantics { + header = "Proxy-Authorization" + } + switch authentication.Mode { + case "", ModeNone: + return noAuthenticator{}, nil + case ModeUsernamePassword: + if authentication.Username == "" || authentication.Password == "" { + return nil, ErrInvalidConfig + } + return basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, nil + case ModeAPIKey: + if !validHeaderName(authentication.Header) || authentication.Token == "" { + return nil, ErrInvalidConfig + } + return tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, nil + case ModeBearer: + if authentication.Token == "" { + return nil, ErrInvalidConfig + } + return tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, nil + case ModeIPWhitelist: + allowed, err := newCIDRMatcher(authentication.CIDRs) + if err != nil || len(authentication.CIDRs) == 0 { + return nil, ErrInvalidConfig + } + return ipAuthenticator{allowed: allowed}, nil + case ModeAny: + if len(authentication.Methods) == 0 { + return nil, ErrInvalidConfig + } + methods := make([]authenticator, 0, len(authentication.Methods)) + for _, method := range authentication.Methods { + candidate, err := buildMethod(method, semantics) + if err != nil { + return nil, err + } + methods = append(methods, candidate) + } + return anyAuthenticator{methods: methods}, nil + default: + return nil, ErrInvalidConfig + } +} + +func buildMethod(method Method, semantics Semantics) (authenticator, error) { + authentication := Authentication{ + Mode: method.Mode, Username: method.Username, Password: method.Password, + Header: method.Header, Token: method.Value, CIDRs: method.CIDRs, + } + if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" { + return nil, ErrInvalidConfig + } + return buildAuthenticator(authentication, semantics) +} + +func parseBasicCredentials(value string) (string, string, bool) { + encoded, ok := parseScheme(value, "Basic", true) + if !ok { + return "", "", false + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", "", false + } + username, password, ok := strings.Cut(string(decoded), ":") + return username, password, ok +} + +func parseScheme(value, expected string, present bool) (string, bool) { + if !present { + return "", false + } + fields := strings.Fields(value) + if len(fields) != 2 || !strings.EqualFold(fields[0], expected) { + return "", false + } + return fields[1], true +} + +func singleHeader(request *http.Request, name string) (string, bool) { + if request == nil { + return "", false + } + values := request.Header.Values(name) + return first(values), len(values) == 1 && values[0] != "" +} + +func first(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +func secureEqual(actual, expected string) int { + actualHash := sha256.Sum256([]byte(actual)) + expectedHash := sha256.Sum256([]byte(expected)) + return subtle.ConstantTimeCompare(actualHash[:], expectedHash[:]) +} + +func credentialSubject(kind, credential string) string { + digest := sha256.Sum256([]byte(credential)) + return kind + ":" + hex.EncodeToString(digest[:16]) +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func validHeaderName(value string) bool { + if value == "" { + return false + } + for _, character := range []byte(value) { + if !isTokenCharacter(character) { + return false + } + } + return true +} + +func isTokenCharacter(character byte) bool { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(character)) +} diff --git a/internal/platform/httpsecurity/config.go b/internal/platform/httpsecurity/config.go new file mode 100644 index 0000000..3d10d1c --- /dev/null +++ b/internal/platform/httpsecurity/config.go @@ -0,0 +1,54 @@ +package httpsecurity + +import ( + "fmt" + "time" + + "github.com/proxy-pool/proxy-pool/internal/config" + platformAdmission "github.com/proxy-pool/proxy-pool/internal/platform/admission" +) + +func BuildFromListener(listener config.Listener, clientIdentification string, semantics Semantics) (*Protection, error) { + var admitter Admitter + if listener.Limits.RequestsPerMinute > 0 || listener.Limits.RequestsPerMinutePerClient > 0 { + limiter, err := platformAdmission.NewFixedWindow(platformAdmission.FixedWindowConfig{ + Window: time.Minute, + Global: listener.Limits.RequestsPerMinute, + PerKey: listener.Limits.RequestsPerMinutePerClient, + }) + if err != nil { + return nil, fmt.Errorf("build HTTP security admission: %w", err) + } + admitter = limiter + } + return NewFromListener(listener, clientIdentification, semantics, admitter) +} + +func NewFromListener(listener config.Listener, clientIdentification string, semantics Semantics, admitter Admitter) (*Protection, error) { + methods := make([]Method, 0, len(listener.Auth.Methods)) + for _, method := range listener.Auth.Methods { + methods = append(methods, Method{ + Mode: method.Mode, + Username: method.Username, + Password: method.Password, + Header: method.Header, + Value: method.Value, + CIDRs: append([]string(nil), method.CIDRs...), + }) + } + return New(Config{ + TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...), + AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...), + Authentication: Authentication{ + Mode: listener.Auth.Mode, + Username: listener.Auth.Username, + Password: listener.Auth.Password, + Header: listener.Auth.Header, + Token: listener.Auth.Token, + CIDRs: append([]string(nil), listener.Auth.CIDRs...), + Methods: methods, + }, + ClientIdentification: clientIdentification, + Semantics: semantics, + }, admitter) +} diff --git a/internal/platform/httpsecurity/config_test.go b/internal/platform/httpsecurity/config_test.go new file mode 100644 index 0000000..467060b --- /dev/null +++ b/internal/platform/httpsecurity/config_test.go @@ -0,0 +1,61 @@ +package httpsecurity + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/proxy-pool/proxy-pool/internal/config" +) + +func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) { + t.Parallel() + listener := config.Listener{ + Access: config.Access{ + AllowCIDRs: []string{"198.51.100.0/24"}, + TrustedProxies: []string{"10.0.0.0/8"}, + }, + Auth: config.Auth{Mode: ModeAny, Methods: []config.AuthMethod{ + {Mode: ModeBearer, Value: "bearer-secret"}, + {Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret"}, + }}, + } + protection, err := NewFromListener(listener, ClientAuthenticated, APIAuthSemantics, nil) + if err != nil { + t.Fatalf("NewFromListener() error = %v", err) + } + request := newRequest() + request.Header.Set("Authorization", "Bearer bearer-secret") + + identity, err := protection.Resolve(request) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if identity.ClientID != credentialSubject("bearer", "bearer-secret") { + t.Fatalf("identity = %+v", identity) + } + if _, err := protection.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); err == nil { + t.Fatal("Resolve(request without remote address) error = nil") + } +} + +func TestBuildFromListenerAppliesConfiguredAdmissionLimits(t *testing.T) { + t.Parallel() + listener := config.Listener{ + Auth: config.Auth{Mode: ModeNone}, + Limits: config.Limits{RequestsPerMinute: 1, RequestsPerMinutePerClient: 1}, + } + protection, err := BuildFromListener(listener, ClientSourceIP, APIAuthSemantics) + if err != nil { + t.Fatalf("BuildFromListener() error = %v", err) + } + if _, err := protection.Resolve(newRequest()); err != nil { + t.Fatalf("Resolve(first) error = %v", err) + } + _, err = protection.Resolve(newRequest()) + var httpError *HTTPError + if !errors.As(err, &httpError) || httpError.StatusCode != http.StatusTooManyRequests { + t.Fatalf("Resolve(second) error = %T %v", err, err) + } +} diff --git a/internal/platform/httpsecurity/protection.go b/internal/platform/httpsecurity/protection.go new file mode 100644 index 0000000..1096406 --- /dev/null +++ b/internal/platform/httpsecurity/protection.go @@ -0,0 +1,122 @@ +package httpsecurity + +import ( + "context" + "errors" + "net/http" +) + +type Protection struct { + resolver *ClientIPResolver + allow cidrMatcher + allowAll bool + authentication authenticator + clientMode string + semantics Semantics + admitter Admitter +} + +func New(config Config, admitter Admitter) (*Protection, error) { + if config.Semantics != APIAuthSemantics && config.Semantics != ProxySemantics { + return nil, ErrInvalidConfig + } + resolver, err := NewClientIPResolver(config.TrustedProxies) + if err != nil { + return nil, ErrInvalidConfig + } + allow, err := newCIDRMatcher(config.AllowCIDRs) + if err != nil { + return nil, ErrInvalidConfig + } + authentication, err := buildAuthenticator(config.Authentication, config.Semantics) + if err != nil { + return nil, ErrInvalidConfig + } + clientMode := config.ClientIdentification + if clientMode == "" { + clientMode = ClientSourceIP + } + if clientMode != ClientSourceIP && clientMode != ClientAuthenticated && clientMode != ClientAuthenticatedOrSourceIP { + return nil, ErrInvalidConfig + } + if clientMode == ClientAuthenticated && (config.Authentication.Mode == "" || config.Authentication.Mode == ModeNone) { + return nil, ErrInvalidConfig + } + return &Protection{ + resolver: resolver, allow: allow, allowAll: len(config.AllowCIDRs) == 0, + authentication: authentication, clientMode: clientMode, semantics: config.Semantics, + admitter: admitter, + }, nil +} + +func (protection *Protection) Resolve(request *http.Request) (Identity, error) { + if request == nil { + return Identity{}, newHTTPError(http.StatusBadRequest, "INVALID_SOURCE", nil, errors.New("request is required")) + } + return protection.evaluate(request.Context(), request) +} + +func (protection *Protection) Check(ctx context.Context, request *http.Request) error { + _, err := protection.evaluate(ctx, request) + return err +} + +func (protection *Protection) evaluate(ctx context.Context, request *http.Request) (Identity, error) { + if protection == nil || protection.resolver == nil || protection.authentication == nil || request == nil { + return Identity{}, newHTTPError(http.StatusInternalServerError, "SECURITY_NOT_CONFIGURED", nil, ErrInvalidConfig) + } + address, err := protection.resolver.Resolve(request) + if err != nil { + return Identity{}, newHTTPError(http.StatusBadRequest, "INVALID_SOURCE", nil, err) + } + if !protection.allowAll && !protection.allow.match(address) { + return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, errSourceRejected) + } + source := address.String() + principal, err := protection.authentication.authenticate(request, source) + if err != nil { + if errors.Is(err, errSourceRejected) { + return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, err) + } + return Identity{}, protection.unauthorized(err) + } + identity := Identity{SourceIP: source} + switch protection.clientMode { + case ClientSourceIP: + identity.ClientID = "source:" + source + case ClientAuthenticated: + if principal == "" { + return Identity{}, protection.unauthorized(errCredentialRejected) + } + identity.ClientID = principal + case ClientAuthenticatedOrSourceIP: + identity.ClientID = principal + if identity.ClientID == "" { + identity.ClientID = "source:" + source + } + } + if protection.admitter != nil { + if err := protection.admitter.Admit(ctx, identity.ClientID); err != nil { + return Identity{}, newHTTPError(http.StatusTooManyRequests, "RATE_LIMITED", nil, err) + } + } + return identity, nil +} + +func (protection *Protection) unauthorized(cause error) *HTTPError { + status := http.StatusUnauthorized + headerName := "WWW-Authenticate" + if protection.semantics == ProxySemantics { + status = http.StatusProxyAuthRequired + headerName = "Proxy-Authenticate" + } + header := make(http.Header) + for _, challenge := range protection.authentication.challenges() { + header.Add(headerName, challenge) + } + return newHTTPError(status, "UNAUTHORIZED", header, cause) +} + +func newHTTPError(status int, code string, header http.Header, cause error) *HTTPError { + return &HTTPError{StatusCode: status, Code: code, Header: header, Cause: cause} +} diff --git a/internal/platform/httpsecurity/response.go b/internal/platform/httpsecurity/response.go new file mode 100644 index 0000000..6b6e048 --- /dev/null +++ b/internal/platform/httpsecurity/response.go @@ -0,0 +1,36 @@ +package httpsecurity + +import ( + "errors" + "net/http" + + "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" +) + +func WriteProblem(writer http.ResponseWriter, requestID string, err error) bool { + var securityError *HTTPError + if !errors.As(err, &securityError) || securityError.StatusCode < 400 || securityError.StatusCode > 599 { + return false + } + for name, values := range securityError.Header { + for _, value := range values { + writer.Header().Add(name, value) + } + } + code := securityError.Code + if code == "" { + code = "SECURITY_REJECTED" + } + title := http.StatusText(securityError.StatusCode) + if title == "" { + title = "Request rejected" + } + httpapi.WriteProblem(writer, httpapi.NewProblem( + securityError.StatusCode, + code, + title, + "", + requestID, + )) + return true +} diff --git a/internal/platform/httpsecurity/response_test.go b/internal/platform/httpsecurity/response_test.go new file mode 100644 index 0000000..a745841 --- /dev/null +++ b/internal/platform/httpsecurity/response_test.go @@ -0,0 +1,50 @@ +package httpsecurity + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteProblemUsesSafeFallbackForCustomStatus(t *testing.T) { + t.Parallel() + recorder := httptest.NewRecorder() + err := &HTTPError{ + StatusCode: 599, + Header: http.Header{"X-Security-Policy": []string{"local"}}, + Cause: errors.New("secret backend details"), + } + + if !WriteProblem(recorder, "request-123", err) { + t.Fatal("WriteProblem() = false, want true") + } + if recorder.Code != 599 { + t.Fatalf("status = %d, want 599", recorder.Code) + } + if got := recorder.Header().Get("X-Security-Policy"); got != "local" { + t.Fatalf("X-Security-Policy = %q, want local", got) + } + body := recorder.Body.String() + for _, want := range []string{`"code":"SECURITY_REJECTED"`, `"title":"Request rejected"`, `"requestId":"request-123"`} { + if !strings.Contains(body, want) { + t.Fatalf("body = %s, want %s", body, want) + } + } + if strings.Contains(body, "secret backend details") { + t.Fatalf("body leaked cause: %s", body) + } +} + +func TestWriteProblemIgnoresNonSecurityErrors(t *testing.T) { + t.Parallel() + recorder := httptest.NewRecorder() + + if WriteProblem(recorder, "request-123", errors.New("plain error")) { + t.Fatal("WriteProblem() = true, want false") + } + if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 { + t.Fatalf("recorder = status %d body %q, want untouched", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/platform/httpsecurity/security_test.go b/internal/platform/httpsecurity/security_test.go new file mode 100644 index 0000000..22a934c --- /dev/null +++ b/internal/platform/httpsecurity/security_test.go @@ -0,0 +1,329 @@ +package httpsecurity + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" +) + +func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) { + t.Parallel() + tests := []struct { + name string + auth Authentication + configure func(*http.Request) + wantClient string + }{ + { + name: "basic", + auth: Authentication{Mode: ModeUsernamePassword, Username: "alice", Password: "secret"}, + configure: func(request *http.Request) { + request.SetBasicAuth("alice", "secret") + }, + wantClient: "basic:alice", + }, + { + name: "api key", + auth: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "api-secret"}, + configure: func(request *http.Request) { + request.Header.Set("X-API-Key", "api-secret") + }, + wantClient: credentialSubject("apiKey", "api-secret"), + }, + { + name: "bearer", + auth: Authentication{Mode: ModeBearer, Token: "bearer-secret"}, + configure: func(request *http.Request) { + request.Header.Set("Authorization", "Bearer bearer-secret") + }, + wantClient: credentialSubject("bearer", "bearer-secret"), + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: test.auth, + ClientIdentification: ClientAuthenticated, + }, nil) + request := newRequest() + test.configure(request) + + identity, err := protection.Resolve(request) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if identity.ClientID != test.wantClient || identity.SourceIP != "198.51.100.8" { + t.Fatalf("identity = %+v", identity) + } + }) + } +} + +func TestProtectionSupportsAnyAuthentication(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeAny, Methods: []Method{ + {Mode: ModeUsernamePassword, Username: "alice", Password: "wrong-for-request"}, + {Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret"}, + }}, + ClientIdentification: ClientAuthenticatedOrSourceIP, + }, nil) + request := newRequest() + request.Header.Set("X-API-Key", "api-secret") + + identity, err := protection.Resolve(request) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if identity.ClientID != credentialSubject("apiKey", "api-secret") { + t.Fatalf("client ID = %q", identity.ClientID) + } +} + +func TestProtectionAnyPreservesSourceRejection(t *testing.T) { + t.Parallel() + tests := []struct { + name string + methods []Method + }{ + { + name: "IP whitelist only", + methods: []Method{{Mode: ModeIPWhitelist, CIDRs: []string{"10.0.0.0/8"}}}, + }, + { + name: "IP whitelist and bearer", + methods: []Method{ + {Mode: ModeIPWhitelist, CIDRs: []string{"10.0.0.0/8"}}, + {Mode: ModeBearer, Value: "bearer-secret"}, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeAny, Methods: test.methods}, + }, nil) + + _, err := protection.Resolve(newRequest()) + var httpError *HTTPError + if !errors.As(err, &httpError) || httpError.StatusCode != http.StatusForbidden || httpError.Code != "FORBIDDEN" { + t.Fatalf("Resolve() error = %+v, want 403 FORBIDDEN", httpError) + } + }) + } +} + +func TestProtectionReturnsSafeHTTPFailures(t *testing.T) { + t.Parallel() + tests := []struct { + name string + config Config + configure func(*http.Request) + admitter Admitter + wantStatus int + wantCode string + wantHeader string + }{ + { + name: "unauthorized basic", + config: Config{Authentication: Authentication{Mode: ModeUsernamePassword, Username: "alice", Password: "secret"}}, + wantStatus: http.StatusUnauthorized, + wantCode: "UNAUTHORIZED", + wantHeader: `Basic realm="proxy-pool"`, + }, + { + name: "unauthorized API key", + config: Config{Authentication: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "secret"}}, + wantStatus: http.StatusUnauthorized, + wantCode: "UNAUTHORIZED", + wantHeader: `ApiKey realm="proxy-pool", header="X-API-Key"`, + }, + { + name: "forbidden source", + config: Config{Authentication: Authentication{Mode: ModeNone}, AllowCIDRs: []string{"10.0.0.0/8"}}, + wantStatus: http.StatusForbidden, + wantCode: "FORBIDDEN", + }, + { + name: "invalid forwarded chain", + config: Config{Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"10.0.0.0/8"}}, + configure: func(request *http.Request) { + request.RemoteAddr = "10.0.0.1:1" + request.Header.Set("Forwarded", "for=unknown") + }, + wantStatus: http.StatusBadRequest, + wantCode: "INVALID_SOURCE", + }, + { + name: "invalid forwarded IPv6 suffix", + config: Config{Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"10.0.0.0/8"}}, + configure: func(request *http.Request) { + request.RemoteAddr = "10.0.0.1:1" + request.Header.Set("Forwarded", `for="[2001:db8::7]junk"`) + }, + wantStatus: http.StatusBadRequest, + wantCode: "INVALID_SOURCE", + }, + { + name: "rate limited", + config: Config{Authentication: Authentication{Mode: ModeNone}}, + admitter: rejectAdmitter{}, + wantStatus: http.StatusTooManyRequests, + wantCode: "RATE_LIMITED", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + protection := mustProtection(t, test.config, test.admitter) + request := newRequest() + if test.configure != nil { + test.configure(request) + } + + _, err := protection.Resolve(request) + var httpError *HTTPError + if !errors.As(err, &httpError) { + t.Fatalf("Resolve() error = %T %v, want *HTTPError", err, err) + } + if httpError.StatusCode != test.wantStatus || httpError.Code != test.wantCode { + t.Fatalf("HTTP error = %+v", httpError) + } + if test.wantHeader != "" && httpError.Header.Get("WWW-Authenticate") != test.wantHeader { + t.Fatalf("challenge = %q", httpError.Header.Get("WWW-Authenticate")) + } + if strings := err.Error(); strings == "" || strings == "secret" { + t.Fatalf("unsafe error = %q", strings) + } + }) + } +} + +func TestProtectionOnlyTrustsForwardedHeadersFromConfiguredPeers(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeNone}, + TrustedProxies: []string{"10.0.0.0/8", "192.0.2.0/24"}, + ClientIdentification: ClientSourceIP, + }, nil) + request := newRequest() + request.RemoteAddr = "10.0.0.9:1234" + request.Header.Set("X-Forwarded-For", "198.51.100.7, 192.0.2.5") + + identity, err := protection.Resolve(request) + if err != nil { + t.Fatalf("Resolve(trusted) error = %v", err) + } + if identity.SourceIP != "198.51.100.7" || identity.ClientID != "source:198.51.100.7" { + t.Fatalf("trusted identity = %+v", identity) + } + + request.RemoteAddr = "203.0.113.9:1234" + identity, err = protection.Resolve(request) + if err != nil { + t.Fatalf("Resolve(untrusted) error = %v", err) + } + if identity.SourceIP != "203.0.113.9" { + t.Fatalf("untrusted identity = %+v", identity) + } +} + +func TestProtectionUsesProxyAuthenticationSemantics(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeBearer, Token: "proxy-token"}, + Semantics: ProxySemantics, + }, nil) + request := newRequest() + request.Header.Set("Authorization", "Bearer proxy-token") + + if _, err := protection.Resolve(request); err == nil { + t.Fatal("Resolve(Authorization) error = nil, want proxy authentication failure") + } + request.Header.Del("Authorization") + request.Header.Set("Proxy-Authorization", "Bearer proxy-token") + if _, err := protection.Resolve(request); err != nil { + t.Fatalf("Resolve(Proxy-Authorization) error = %v", err) + } + + request.Header.Set("Proxy-Authorization", "Bearer wrong") + _, err := protection.Resolve(request) + var httpError *HTTPError + if !errors.As(err, &httpError) || httpError.StatusCode != http.StatusProxyAuthRequired || + httpError.Header.Get("Proxy-Authenticate") != `Bearer realm="proxy-pool"` { + t.Fatalf("proxy error = %+v", httpError) + } +} + +func TestNewProtectionRejectsInvalidConfiguration(t *testing.T) { + t.Parallel() + tests := []Config{ + {Authentication: Authentication{Mode: "unknown"}}, + {Authentication: Authentication{Mode: ModeAPIKey, Header: "Bad Header", Token: "secret"}}, + {Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"invalid"}}, + {Authentication: Authentication{Mode: ModeNone}, ClientIdentification: "unknown"}, + {Authentication: Authentication{Mode: ModeNone}, ClientIdentification: ClientAuthenticated}, + } + for _, config := range tests { + if _, err := New(config, nil); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("New(%+v) error = %v, want %v", config, err, ErrInvalidConfig) + } + } +} + +func TestProtectionResolvesConcurrentRequestsWithoutSharedMutation(t *testing.T) { + t.Parallel() + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "api-secret"}, + ClientIdentification: ClientAuthenticated, + }, nil) + + var failures atomic.Int64 + var wait sync.WaitGroup + for range 1000 { + wait.Add(1) + go func() { + defer wait.Done() + request := newRequest() + request.Header.Set("X-API-Key", "api-secret") + identity, err := protection.Resolve(request) + if err != nil || identity.ClientID != credentialSubject("apiKey", "api-secret") { + failures.Add(1) + } + }() + } + wait.Wait() + if failures.Load() != 0 { + t.Fatalf("concurrent resolve failures = %d", failures.Load()) + } +} + +func mustProtection(t *testing.T, config Config, admitter Admitter) *Protection { + t.Helper() + protection, err := New(config, admitter) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return protection +} + +func newRequest() *http.Request { + request := httptest.NewRequest(http.MethodGet, "http://example.test", nil) + request.RemoteAddr = "198.51.100.8:1234" + return request +} + +type rejectAdmitter struct{} + +func (rejectAdmitter) Admit(context.Context, string) error { return errors.New("backend details") } diff --git a/internal/platform/httpsecurity/source.go b/internal/platform/httpsecurity/source.go new file mode 100644 index 0000000..09f239c --- /dev/null +++ b/internal/platform/httpsecurity/source.go @@ -0,0 +1,167 @@ +package httpsecurity + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strconv" + "strings" +) + +type cidrMatcher struct { + prefixes []netip.Prefix +} + +func newCIDRMatcher(values []string) (cidrMatcher, error) { + matcher := cidrMatcher{prefixes: make([]netip.Prefix, 0, len(values))} + for _, value := range values { + prefix, err := netip.ParsePrefix(strings.TrimSpace(value)) + if err != nil { + return cidrMatcher{}, fmt.Errorf("%w: invalid CIDR", ErrInvalidConfig) + } + matcher.prefixes = append(matcher.prefixes, prefix.Masked()) + } + return matcher, nil +} + +func (matcher cidrMatcher) match(address netip.Addr) bool { + address = address.Unmap() + for _, prefix := range matcher.prefixes { + if prefix.Contains(address) { + return true + } + } + return false +} + +type ClientIPResolver struct { + trusted cidrMatcher +} + +func NewClientIPResolver(trustedCIDRs []string) (*ClientIPResolver, error) { + trusted, err := newCIDRMatcher(trustedCIDRs) + if err != nil { + return nil, err + } + return &ClientIPResolver{trusted: trusted}, nil +} + +func (resolver *ClientIPResolver) Resolve(request *http.Request) (netip.Addr, error) { + if resolver == nil || request == nil { + return netip.Addr{}, errors.New("client IP resolver and request are required") + } + peer, err := parseRemoteAddress(request.RemoteAddr) + if err != nil { + return netip.Addr{}, err + } + if !resolver.trusted.match(peer) { + return peer, nil + } + + chain, present, err := parseForwardedChain(request.Header.Values("Forwarded")) + if err != nil { + return netip.Addr{}, err + } + if !present { + chain, err = parseXForwardedFor(request.Header.Values("X-Forwarded-For")) + if err != nil { + return netip.Addr{}, err + } + } + if len(chain) == 0 { + return peer, nil + } + for index := len(chain) - 1; index >= 0; index-- { + if !resolver.trusted.match(chain[index]) { + return chain[index], nil + } + } + return chain[0], nil +} + +func parseRemoteAddress(remote string) (netip.Addr, error) { + host, _, err := net.SplitHostPort(strings.TrimSpace(remote)) + if err != nil { + host = strings.TrimSpace(remote) + } + address, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, errors.New("invalid remote address") + } + return address.Unmap(), nil +} + +func parseXForwardedFor(fields []string) ([]netip.Addr, error) { + chain := make([]netip.Addr, 0, len(fields)+1) + for _, field := range fields { + for value := range strings.SplitSeq(field, ",") { + address, err := netip.ParseAddr(strings.TrimSpace(value)) + if err != nil { + return nil, errors.New("invalid X-Forwarded-For address") + } + chain = append(chain, address.Unmap()) + } + } + return chain, nil +} + +func parseForwardedChain(fields []string) ([]netip.Addr, bool, error) { + if len(fields) == 0 { + return nil, false, nil + } + chain := make([]netip.Addr, 0, len(fields)+1) + for _, field := range fields { + for element := range strings.SplitSeq(field, ",") { + found := false + for parameter := range strings.SplitSeq(element, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(parameter), "=") + if !ok || !strings.EqualFold(name, "for") { + continue + } + address, err := parseForwardedIdentifier(value) + if err != nil { + return nil, true, err + } + chain = append(chain, address) + found = true + break + } + if !found { + return nil, true, errors.New("Forwarded element is missing for parameter") + } + } + } + return chain, true, nil +} + +func parseForwardedIdentifier(raw string) (netip.Addr, error) { + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, `"`) { + unquoted, err := strconv.Unquote(value) + if err != nil { + return netip.Addr{}, errors.New("invalid quoted Forwarded identifier") + } + value = unquoted + } + if strings.EqualFold(value, "unknown") || strings.HasPrefix(value, "_") { + return netip.Addr{}, errors.New("non-IP Forwarded identifier") + } + if strings.HasPrefix(value, "[") { + if addressPort, err := netip.ParseAddrPort(value); err == nil { + return addressPort.Addr().Unmap(), nil + } + if !strings.HasSuffix(value, "]") { + return netip.Addr{}, errors.New("invalid Forwarded IPv6 identifier") + } + value = strings.TrimSuffix(strings.TrimPrefix(value, "["), "]") + } else if host, _, err := net.SplitHostPort(value); err == nil { + value = host + } + address, err := netip.ParseAddr(value) + if err != nil { + return netip.Addr{}, errors.New("invalid Forwarded address") + } + return address.Unmap(), nil +} diff --git a/internal/platform/httpsecurity/types.go b/internal/platform/httpsecurity/types.go new file mode 100644 index 0000000..01454d3 --- /dev/null +++ b/internal/platform/httpsecurity/types.go @@ -0,0 +1,86 @@ +package httpsecurity + +import ( + "context" + "errors" + "net/http" +) + +const ( + ModeNone = "none" + ModeUsernamePassword = "usernamePassword" + ModeAPIKey = "apiKey" + ModeBearer = "bearer" + ModeIPWhitelist = "ipWhitelist" + ModeAny = "any" + + ClientSourceIP = "sourceIP" + ClientAuthenticated = "authenticatedClient" + ClientAuthenticatedOrSourceIP = "authenticatedClientOrSourceIP" +) + +const ( + APIAuthSemantics Semantics = iota + ProxySemantics +) + +var ErrInvalidConfig = errors.New("invalid HTTP security configuration") + +type Semantics uint8 + +type Authentication struct { + Mode string + Username string + Password string + Header string + Token string + CIDRs []string + Methods []Method +} + +type Method struct { + Mode string + Username string + Password string + Header string + Value string + CIDRs []string +} + +type Config struct { + TrustedProxies []string + AllowCIDRs []string + Authentication Authentication + ClientIdentification string + Semantics Semantics +} + +type Identity struct { + ClientID string + SourceIP string +} + +type Admitter interface { + Admit(context.Context, string) error +} + +type HTTPError struct { + StatusCode int + Code string + Header http.Header + Cause error +} + +func (err *HTTPError) Error() string { + if err == nil || err.Code == "" { + return "HTTP security request rejected" + } + return err.Code +} + +func (err *HTTPError) Unwrap() error { + if err == nil { + return nil + } + return err.Cause +}