版本:v2.18.0 | 最后更新:2026-03-22
This document describes the security model of PairProxy, the threats it addresses, the mitigations in place, and operational hardening recommendations.
PairProxy sits between internal developer tooling (Claude Code) and commercial LLM APIs. The primary threats are:
| Threat | Impact |
|---|---|
| Unauthorized LLM access | API cost without accountability |
| Token theft / replay | Impersonation, quota bypass |
| JWT algorithm confusion | Forged tokens accepted by s-proxy |
| Unauthenticated cluster API | Worker injection, usage data manipulation |
| Config misconfiguration at startup | Silent security degradation |
| Resource exhaustion (quota bypass) | Runaway API spend |
| keygen_secret leakage | All Direct Proxy API Keys forgeable |
| Classifier data exfiltration | User message content leaked to external service |
| Corpus data at rest | Training data exposed via filesystem access |
All JWTs issued by s-proxy use HMAC-SHA256 (HS256). The Parse() function
enforces this with an explicit algorithm check:
if token.Method.Alg() != "HS256" → reject with ErrInvalidToken
This prevents algorithm confusion attacks where an attacker signs a token with a different HMAC variant (HS384, HS512) or an asymmetric algorithm (RS256 with the HS256 secret as the public key) in an attempt to bypass signature verification.
What is NOT accepted:
HS384,HS512— even if signed with the correct secretRS256,ES256— asymmetric algorithmsnone— unsigned tokens
A short-lived in-memory blacklist stores revoked JTI (JWT ID) values. When
sproxy admin token revoke <username> is called:
- The user's refresh token is deleted from the database.
- The access token's JTI is added to the in-memory blacklist with a TTL equal to the token's remaining validity.
On Parse(), blacklisted JTIs are rejected with ErrTokenRevoked.
Caveat: the blacklist is in-memory. A s-proxy restart clears it. On
restart, still-valid access tokens from revoked users will be accepted until
they expire naturally (≤ 24h by default). For immediate revocation, set
access_token_ttl to a short value (e.g., 1h).
auth.jwt_secretmust be set (config validation rejects empty values).- Use environment variable substitution (
${JWT_SECRET}) — never commit secrets to version control. - Minimum recommended length: 32 random bytes (
openssl rand -hex 32).
sk-pp- 前缀 API Key 是另一种接入方式,用户通过 API Key 直接访问 sproxy 而无需 cproxy 和 JWT Token。
v2.15.0 起,API Key 使用 HMAC-SHA256 算法生成,替换了早期的指纹嵌入算法:
- 确定性生成:相同用户名 + keygen_secret → 相同 API Key,服务器无需存储 Key
- 抗碰撞:消除了早期算法中
alice123和321ecila生成相同 Key 的碰撞漏洞 - 256 位安全强度:HMAC-SHA256 输出截断后取 Base62 编码(48 字符)
auth.keygen_secret 是必填配置字段(v2.15.0+),违反以下要求时启动配置验证失败:
- 长度 ≥ 32 字符
- 建议使用
openssl rand -hex 32生成
| 风险 | 缓解措施 |
|---|---|
| keygen_secret 泄漏导致所有 API Key 可伪造 | 使用环境变量注入,定期轮换 |
| API Key 无过期时间 | 轮换 keygen_secret 可立即使所有 Key 失效 |
| sk-pp- Key 明文传输 | 要求 HTTPS 接入,同 JWT Token |
- 生成新 secret:
openssl rand -hex 32 - 通知所有 Direct Proxy 用户获取新 API Key
- 更新所有节点的
auth.keygen_secret - 重启所有 sproxy 节点
- 通知用户从 Dashboard/CLI 重新获取 sk-pp- Key
The cluster internal API (/api/internal/register, /api/internal/usage,
/cluster/routing) uses a shared Bearer token (cluster.shared_secret).
The primary operates fail-closed: if shared_secret is empty, all
requests to the cluster internal API are rejected with HTTP 401 and a WARN
log entry. There is no unauthenticated mode.
| Scenario | Behavior |
|---|---|
shared_secret empty on primary |
All cluster API requests → 401 (WARN logged) |
shared_secret set, correct token |
Request accepted |
shared_secret set, wrong token |
Request → 401 (WARN logged) |
shared_secret set, no Authorization header |
Request → 401 (WARN logged) |
- Generate a strong secret (≥ 32 random bytes):
openssl rand -hex 32
- Set it as
cluster.shared_secreton the primary and all workers using the same value. - Use
${CLUSTER_SECRET}substitution — inject via environment variable. - Restrict network access to
/api/internal/*to the cluster's private network only (firewall / security group rules).
On a single-node deployment (no workers), leave shared_secret empty. The
internal API will never be called by any worker, so the fail-closed rejection
is harmless.
Both LoadSProxyConfig and LoadCProxyConfig run a Validate() check
immediately after loading and applying defaults. The process exits early with a
descriptive error if any required field is missing or out of range.
| Field | Rule |
|---|---|
auth.jwt_secret |
Must be non-empty |
auth.keygen_secret |
Must be ≥ 32 characters (v2.15.0+) |
database.path |
Must be non-empty |
llm.targets |
Must have at least one entry |
listen.port |
Must be in range 1–65535 |
cluster.role |
Must be primary or worker (or empty, treated as primary) |
cluster.primary |
Required when role = worker |
Validate() collects all validation errors before returning, so a
misconfigured deployment reports every problem at once rather than failing on
the first error.
| Field | Rule |
|---|---|
listen.port |
Must be in range 1–65535 |
Both the sproxy start command and all sproxy admin sub-commands open a GORM
database connection and close it via defer before the process exits:
sproxy start → defer closeGormDB() (covers both normal exit and fatal errors)
sproxy admin → each subcommand defers closeGormDB() individually
This prevents SQLite WAL file corruption and leaked file descriptors.
v2.13.0 起支持 PostgreSQL 作为数据库后端(替代 SQLite)。
- 使用 SSL/TLS 连接:在 DSN 中添加
sslmode=require - 使用专用数据库用户,最小权限(仅 CRUD 权限,无 DDL 权限用于生产环境)
- 通过环境变量注入连接字符串:
${PG_DSN},避免明文写入配置文件
database:
driver: postgres
dsn: "${PG_DSN}"
# PG_DSN = "host=pg.company.com user=pairproxy password=xxx dbname=pairproxy sslmode=require"-- 创建专用用户
CREATE USER pairproxy WITH PASSWORD 'strong-password';
GRANT CONNECT ON DATABASE pairproxy TO pairproxy;
GRANT USAGE ON SCHEMA public TO pairproxy;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO pairproxy;Quotas are enforced per user group. When a user exceeds their group quota:
- The request is rejected with HTTP 429 Too Many Requests.
- Response headers
X-RateLimit-Limit,X-RateLimit-Used,X-RateLimit-Resetprovide machine-readable quota information. - An optional alert webhook is called (async, fire-and-forget).
Fail-open caveat: if the database is unreachable during a quota check, the request is allowed (fail-open). This prioritizes availability over strict enforcement. If strict enforcement is critical, monitor database health.
RPM limits use a per-user sliding window (1-minute window). When the limit is exceeded, the request is rejected with HTTP 429. The rate limiter is automatically purged of stale entries every minute.
Admin passwords are stored as bcrypt hashes (work factor 12 by default). Plaintext passwords are never written to disk or logged.
User login credentials are transmitted only over HTTPS. In development environments, ensure that the connection between c-proxy and s-proxy uses TLS (e.g., place s-proxy behind an nginx/Caddy reverse proxy with a TLS certificate).
When auth.provider: "ldap" is configured:
- Credentials flow: The user's plaintext password is forwarded from s-proxy to the LDAP server over the network. Always use
use_tls: true(LDAPS) in production to prevent credential interception. - Service account: The
bind_dn/bind_passwordare used only for searching user DNs (read-only). Storebind_passwordin an environment variable (e.g.,${LDAP_BIND_PASSWORD}). - JIT provisioning: On first successful LDAP login, a
usersrow is created withauth_provider = 'ldap'andpassword_hash = ''. These users cannot log in via the local auth path. - User disabling: Disabling a user in s-proxy (
sproxy admin user disable) prevents login even if the LDAP account is still active. The reverse (LDAP account disabled, s-proxy user still active) is not automatically enforced — the user can still obtain a token until the s-proxy entry is disabled. - JIT users have no group: Newly provisioned LDAP users have no group (unlimited quota). Assign groups via the Admin Dashboard or API after provisioning.
PairProxy uses structured logging (zap) with the following security-relevant log entries:
| Event | Level | Fields |
|---|---|---|
| JWT verification failure | WARN | remote_addr, path, error |
| JWT algorithm mismatch | WARN | got_alg, want_alg |
| Cluster API auth failure | WARN | remote_addr, path |
Cluster shared_secret not configured |
WARN | remote_addr, path |
| Quota exceeded | WARN | user_id, kind (daily/monthly/rate_limit), reset_at |
| Admin login failure | WARN | implicit (401 response) |
| Token revoked | INFO | user_id, jti |
Set log.level: debug during security testing to enable per-request verbose logs.
v2.4.0+
The sproxy admin track feature records full conversation content (user messages and assistant replies) to disk. This data is sensitive and requires explicit operational controls.
Each conversation record (track/conversations/<username>/*.json) contains:
- Full message content sent by the user to the LLM
- Full assistant reply text
- Request metadata (timestamp, model, token counts)
This data is not encrypted at rest. The file permissions are 0644 by default.
| Control | Action |
|---|---|
| File permissions | chmod 600 <track_dir>/conversations/<username>/*.json or set umask |
| Directory ownership | Ensure only the sproxy service user can read the directory |
| Encryption at rest | Use filesystem-level encryption (LUKS, eCryptfs) for the data volume |
| Retention policy | Run sproxy admin track clear <username> periodically, or use find … -mtime +N -delete |
| Minimal scope | Enable tracking only for specific users and only for the duration needed |
| Access audit | Log admin CLI access; sproxy admin track show reads files locally |
Enabling conversation tracking without the user's knowledge may violate privacy regulations (GDPR, PIPL, etc.) in your jurisdiction. Ensure appropriate legal basis and disclosure before enabling tracking on production users.
log.debug_file (if configured) logs raw HTTP bytes for all users. Ensure it is unset or pointed to a secured path in production. The track feature is more targeted but has the same confidentiality requirements.
corpus 功能以 JSONL 格式采集 LLM 请求/响应对,用于训练语料收集。
- JSONL 文件存储在配置的
corpus.output_dir目录 - 按日期 + 大小进行文件轮转(如
corpus-2026-03-22.jsonl) - 数据不加密,文件权限默认 0644
以下内容不会被采集(降低隐私风险):
- 错误响应(status != 200)
- 极短回复(< N tokens)
- 被
excluded_groups配置排除的分组
与 Conversation Tracking 相同的控制措施:文件权限 600、目录权限仅 sproxy 用户可读、定期清理(sproxy admin corpus 命令管理)、采集前获取合法依据。
Please report security vulnerabilities by opening a private GitHub Security
Advisory at: https://github.com/l17728/pairproxy/security/advisories/new
Do not file public GitHub issues for security vulnerabilities.
Config files contain secrets (JWT secret, API keys, admin password hash). Restrict read access to the service account only:
chmod 600 /etc/pairproxy/sproxy.yaml
chown sproxy:sproxy /etc/pairproxy/sproxy.yaml
chmod 600 /etc/pairproxy/sproxy-worker.yaml
chown sproxy:sproxy /etc/pairproxy/sproxy-worker.yaml
# Verify
ls -la /etc/pairproxy/
# -rw------- 1 sproxy sproxy ... sproxy.yamlThe SQLite database contains usage logs and user credentials:
chmod 640 /var/lib/pairproxy/pairproxy.db
chmod 640 /var/lib/pairproxy/pairproxy.db-wal
chown sproxy:sproxy /var/lib/pairproxy/
chmod 750 /var/lib/pairproxy/The c-proxy JWT token file should be readable only by the local user:
chmod 600 ~/.config/pairproxy/token.json # Linux/macOS
# Windows: stored in %APPDATA%\pairproxy\token.json (accessible only to current user)PairProxy itself does not terminate TLS. Place a reverse proxy (nginx or
Caddy) in front of sproxy for HTTPS in production.
server {
listen 443 ssl http2;
server_name proxy.company.com;
ssl_certificate /etc/letsencrypt/live/proxy.company.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.company.com/privkey.pem;
# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...';
ssl_prefer_server_ciphers off;
# SSE requires disabled buffering
proxy_buffering off;
# LLM extended thinking 可静默超过 30 分钟;设为 0 表示不限制,
# 依赖 sproxy 自身的客户端断开检测来回收挂起连接。
proxy_read_timeout 0;
proxy_send_timeout 0;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name proxy.company.com;
return 301 https://$host$request_uri;
}proxy.company.com {
# Caddy automatically obtains and renews TLS certs via Let's Encrypt
# Disable response buffering (required for SSE / streaming)
@streaming {
header Content-Type text/event-stream
}
flush_interval -1
reverse_proxy 127.0.0.1:9000 {
flush_interval -1
transport http {
read_buffer 0
}
}
}Update cproxy.yaml to use the HTTPS address:
sproxy:
primary: "https://proxy.company.com" # Note: HTTPS, default port 443For the cluster internal API, restrict firewall access so only s-proxy nodes can reach each other:
# Linux: allow port 9000 only from cluster subnet
ufw allow from 10.0.0.0/24 to any port 9000
ufw deny 9000
# Or iptables
iptables -A INPUT -p tcp --dport 9000 -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 9000 -j DROP- Generate a new API key in the Anthropic Console.
- Add the new key to
sproxy.yamlalongside the existing key:llm: targets: - url: "https://api.anthropic.com" api_key: "${ANTHROPIC_API_KEY_1}" # existing weight: 1 - url: "https://api.anthropic.com" api_key: "${ANTHROPIC_API_KEY_NEW}" # new weight: 1
- Export the new environment variable and send SIGHUP (Linux) or restart:
Note: API key changes require a restart on Linux because SIGHUP only reloads
export ANTHROPIC_API_KEY_NEW=sk-ant-new-key kill -HUP $(pidof sproxy) # Linux/macOS only # Windows: restart the service
log.level. Restart the process to pick up new key values. - Verify the new key is working (check sproxy logs for LLM requests).
- Revoke the old key in the Anthropic Console.
- Remove the old key from config and restart.
Rotating the JWT secret invalidates all existing tokens immediately. Plan for a maintenance window.
Steps:
- Generate a new secret:
openssl rand -hex 32
- Update
JWT_SECRETenvironment variable on all s-proxy nodes (primary + workers). - Restart all s-proxy instances.
- Notify users to run
cproxy loginagain (their existing tokens are invalid).
Mitigating disruption:
- Short-lived access tokens (24h default) limit blast radius if the secret leaks.
- Consider setting
access_token_ttl: 1hbefore rotating to minimize re-login friction.
The cluster shared secret protects worker → primary communication.
Steps:
- Generate a new secret:
openssl rand -hex 32
- Update
CLUSTER_SECRETon the primary node first. - Restart the primary (workers will get 401 during the window — they retry).
- Update
CLUSTER_SECRETon each worker and restart. - Verify workers re-register (check
GET /cluster/routingoutput).
Rolling restart (zero downtime for end users):
- End users connect to c-proxy, which routes to healthy nodes.
- c-proxy health checker detects nodes that go down during restart and re-routes.
- Restart one node at a time, waiting for it to rejoin the cluster before the next.
语义路由分类器将用户消息内容发送给另一个 LLM 进行意图分类。
- 分类器请求:用户 messages 的副本被发送给分类器端点(默认为本机 sproxy,使用独立 LLM Target Pool)
- 递归防止:分类器复用现有 LB 但跳过语义路由层,防止无限递归
- 分类失败降级:分类器超时或错误时,自动降级为完整候选池路由,不影响请求处理
若分类器端点配置为外部服务(非本机),用户消息内容将被发送至该外部服务。
建议:
- 优先使用本机 sproxy 作为分类器(默认配置)
- 若必须使用外部分类器,确保其满足与主 LLM 相同的隐私合规要求
- 审计
semantic_router.classifier_url配置
语义路由规则的 REST API(/api/admin/semantic-router/*)和 CLI(sproxy admin semantic-router)均需要 admin 身份认证,遵循与其他 admin API 相同的鉴权策略。