Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e9c0183
fix(security): charge request memory by actual reads
DeliciousBuding Oct 18, 2026
3e60464
fix(proxy): bound parse-cache memory and oversized input arrays
DeliciousBuding Oct 18, 2026
0482f9e
fix(images): cap external input images and trim the 202 receipt
DeliciousBuding Oct 18, 2026
1e84e02
fix(proxy): cap concurrent downstream WebSocket connections
DeliciousBuding Oct 18, 2026
587bcf7
fix(auth): keep self-service OAuth callbacks in pending review
DeliciousBuding Oct 18, 2026
a7fda91
fix(security): redact proxy credentials and TLS-wrap https proxies
DeliciousBuding Oct 18, 2026
946d357
chore(deploy): fix log workdir and validate build versions
DeliciousBuding Oct 18, 2026
9554e25
test(security): use locked budget snapshot in prepay test
DeliciousBuding Oct 18, 2026
f984972
fix: address security review feedback
DeliciousBuding Oct 18, 2026
5ff88cd
fix: address remaining review feedback
DeliciousBuding Oct 18, 2026
11a9a36
fix: close quota burst and upstream memory gaps
DeliciousBuding Oct 18, 2026
27dab23
fix: address admission review feedback
DeliciousBuding Oct 18, 2026
3d0a136
test(admin): assert no-store on authenticated responses
DeliciousBuding Oct 18, 2026
92da4c2
test(ui): wait for concurrency badge before measuring layout
DeliciousBuding Oct 18, 2026
8a21315
fix(oauth): escape callback page and reject replayed sessions
DeliciousBuding Oct 18, 2026
c3994e3
docs: document API key admission counting and request bounds
DeliciousBuding Oct 18, 2026
b4c71d6
fix(security): close audit bypasses and bound upstream reads
DeliciousBuding Oct 18, 2026
8951bf5
docs: clarify upstream read-budget coverage
DeliciousBuding Oct 18, 2026
8d847c8
fix(security): finish OAuth session and admission guards
DeliciousBuding Oct 18, 2026
7b8b9d0
test(admin): isolate OAuth exchange counts and cover saturated admission
DeliciousBuding Oct 18, 2026
c4435b4
fix(admin): request fresh authorization after consumed session fails
DeliciousBuding Oct 18, 2026
61d09aa
fix(admin): save pending account review metadata atomically
DeliciousBuding Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ Thumbs.db
# Env
.env
.env.local
.env.*
!.env.example

# Logs and local worktrees must not enter the build context.
logs/
*.log
.worktrees/
.local/

# Local credentials and account-test fixtures must never enter a build context.
.private-account-fixtures/
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v5

- name: Validate build version
env:
BUILD_VERSION: ${{ inputs.build_version || github.ref_name }}
run: |
if [[ "$BUILD_VERSION" == *$'\n'* || "$BUILD_VERSION" == *$'\r'* ]]; then
echo "Invalid BUILD_VERSION: newlines are not allowed" >&2
exit 1
fi
if ! [[ "$BUILD_VERSION" =~ ^[0-9A-Za-z._-]{1,64}$ ]]; then
echo "Invalid BUILD_VERSION: must match ^[0-9A-Za-z._-]{1,64}$" >&2
exit 1
fi

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Test PostgreSQL trace, indexes, capabilities and intelligence checks
- name: Test PostgreSQL compatibility and pending-account transactions
env:
CODEX2API_TEST_POSTGRES_DSN: postgres://codex2api_test:test-only@localhost:5432/codex2api_test?sslmode=disable
run: go test -race -count=1 ./database -run '^(TestPostgresTraceAndCapabilities|TestIntelligenceCheckPostgres)$'
run: go test -race -count=1 ./database -run '^(TestPostgresTraceAndCapabilities|TestIntelligenceCheckPostgres|TestPendingAccountTransaction)$'

test-race:
name: test-race (${{ matrix.name }})
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ RUN apk --no-cache add ca-certificates tzdata

COPY --from=go-builder /codex2api /usr/local/bin/codex2api

# 默认 LOG_DIR=logs 是相对路径;固定工作目录才能落到 compose 挂载的 /app/logs,
# 否则审计日志会写到容器根目录的 /logs,容器重建后丢失。
WORKDIR /app

EXPOSE 8080

ENTRYPOINT ["/usr/local/bin/codex2api"]
3 changes: 3 additions & 0 deletions admin/accounts_paged.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ func parseAccountPageQuery(c *gin.Context) (accountPageQuery, error) {
if err != nil || value < 1 {
return query, fmt.Errorf("page must be a positive integer")
}
if value > maxAdminPage {
return query, fmt.Errorf("page is too large")
}
query.Page = value
}
if raw := strings.TrimSpace(c.Query("page_size")); raw != "" {
Expand Down
120 changes: 120 additions & 0 deletions admin/auth_throttle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package admin

import (
"sync"
"time"
)

const (
adminAuthFailureWindow = 10 * time.Minute
adminAuthBlockDuration = 10 * time.Minute
adminAuthMaxFailures = 5
adminAuthMaxEntries = 4096
)

// adminAuthThrottle 记录按 IP 的管理端鉴权失败次数。
// 只在失败时计数;达到阈值后在密钥比较前阻断该 IP 的所有认证尝试。
type adminAuthThrottle struct {
mu sync.Mutex
entries map[string]*adminAuthThrottleEntry
}

type adminAuthThrottleEntry struct {
failures int
windowStart time.Time
blockedUntil time.Time
}

func newAdminAuthThrottle() *adminAuthThrottle {
return &adminAuthThrottle{entries: make(map[string]*adminAuthThrottleEntry)}
}

// retryAfter 返回该 IP 剩余的阻断时间;未阻断时返回 0。
func (t *adminAuthThrottle) retryAfter(ip string, now time.Time) time.Duration {
if t == nil {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
entry := t.entries[ip]
if entry == nil {
t.evictLocked(now)
return t.capacityRetryAfterLocked(now)
}
if !now.Before(entry.blockedUntil) {
return 0
}
return entry.blockedUntil.Sub(now)
}

// recordFailure 记录一次失败;达到阈值时开始阻断并返回阻断时长。
func (t *adminAuthThrottle) recordFailure(ip string, now time.Time) time.Duration {
if t == nil {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
entry := t.entries[ip]
if entry == nil {
t.evictLocked(now)
if retry := t.capacityRetryAfterLocked(now); retry > 0 {
return retry
}
entry = &adminAuthThrottleEntry{windowStart: now}
t.entries[ip] = entry
}
if now.Before(entry.blockedUntil) {
return entry.blockedUntil.Sub(now)
}
if !now.Before(entry.windowStart.Add(adminAuthFailureWindow)) {
entry.failures = 0
entry.windowStart = now
}
entry.failures++
if entry.failures < adminAuthMaxFailures {
return 0
}
entry.failures = 0
entry.windowStart = now
entry.blockedUntil = now.Add(adminAuthBlockDuration)
return adminAuthBlockDuration
}

func (t *adminAuthThrottle) reset(ip string) {
if t == nil {
return
}
t.mu.Lock()
delete(t.entries, ip)
t.mu.Unlock()
}

// evictLocked only removes expired entries; live failures and blocks survive saturation.
func (t *adminAuthThrottle) evictLocked(now time.Time) {
if len(t.entries) < adminAuthMaxEntries {
return
}
for ip, entry := range t.entries {
if !now.Before(entry.blockedUntil) && !now.Before(entry.windowStart.Add(adminAuthFailureWindow)) {
delete(t.entries, ip)
}
}
}

// Untracked sources wait for the first slot to expire before key comparison.
func (t *adminAuthThrottle) capacityRetryAfterLocked(now time.Time) time.Duration {
if len(t.entries) < adminAuthMaxEntries {
return 0
}
var earliest time.Time
for _, entry := range t.entries {
expires := entry.windowStart.Add(adminAuthFailureWindow)
if entry.blockedUntil.After(expires) {
expires = entry.blockedUntil
}
if earliest.IsZero() || expires.Before(earliest) {
earliest = expires
}
}
return earliest.Sub(now)
}
168 changes: 168 additions & 0 deletions admin/auth_throttle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package admin

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gin-gonic/gin"
)

func TestAdminAuthThrottleBlocksAfterFailures(t *testing.T) {
throttle := newAdminAuthThrottle()
now := time.Unix(1_700_000_000, 0)

for i := 0; i < adminAuthMaxFailures-1; i++ {
if blocked := throttle.recordFailure("203.0.113.9", now); blocked != 0 {
t.Fatalf("failure %d blocked early: %v", i+1, blocked)
}
}
if retry := throttle.retryAfter("203.0.113.9", now); retry != 0 {
t.Fatalf("retryAfter before threshold = %v, want 0", retry)
}

if blocked := throttle.recordFailure("203.0.113.9", now); blocked != adminAuthBlockDuration {
t.Fatalf("threshold failure blocked for %v, want %v", blocked, adminAuthBlockDuration)
}
if retry := throttle.retryAfter("203.0.113.9", now.Add(time.Minute)); retry != adminAuthBlockDuration-time.Minute {
t.Fatalf("retryAfter = %v, want %v", retry, adminAuthBlockDuration-time.Minute)
}
if retry := throttle.retryAfter("203.0.113.10", now); retry != 0 {
t.Fatalf("other IP should not be blocked, got %v", retry)
}
if retry := throttle.retryAfter("203.0.113.9", now.Add(adminAuthBlockDuration)); retry != 0 {
t.Fatalf("block should expire, got %v", retry)
}
}

func TestAdminAuthThrottleResetsOnSuccess(t *testing.T) {
throttle := newAdminAuthThrottle()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < adminAuthMaxFailures-1; i++ {
throttle.recordFailure("203.0.113.11", now)
}
throttle.reset("203.0.113.11")
if blocked := throttle.recordFailure("203.0.113.11", now); blocked != 0 {
t.Fatalf("reset should clear failure count, blocked for %v", blocked)
}
}

func TestAdminAuthThrottleExpiresOldFailures(t *testing.T) {
throttle := newAdminAuthThrottle()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < adminAuthMaxFailures-1; i++ {
throttle.recordFailure("203.0.113.12", now)
}
later := now.Add(adminAuthFailureWindow + time.Second)
if blocked := throttle.recordFailure("203.0.113.12", later); blocked != 0 {
t.Fatalf("failures outside the window should not accumulate, blocked for %v", blocked)
}
}

func TestAdminAuthMiddlewareThrottlesFailedAttempts(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newSelfServiceHandler(t, false)
router := gin.New()
handler.RegisterRoutes(router)

request := func(key string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", nil)
req.Header.Set("X-Admin-Key", key)
router.ServeHTTP(rec, req)
return rec
}

for i := 0; i < adminAuthMaxFailures-1; i++ {
if rec := request("wrong-key"); rec.Code != http.StatusUnauthorized {
t.Fatalf("failure %d returned %d, want 401", i+1, rec.Code)
}
}
if rec := request("wrong-key"); rec.Code != http.StatusTooManyRequests {
t.Fatalf("threshold failure returned %d, want 429", rec.Code)
}
if rec := request("wrong-key"); rec.Code != http.StatusTooManyRequests {
t.Fatalf("failure during block returned %d, want 429", rec.Code)
} else if rec.Header().Get("Retry-After") == "" {
t.Fatal("429 response missing Retry-After")
}
if rec := request("admin-secret"); rec.Code != http.StatusTooManyRequests {
t.Fatalf("correct key during block returned %d, want 429 (no password oracle)", rec.Code)
}
// Expiry must allow normal authentication again, including no-store headers.
handler.adminAuthThrottle.mu.Lock()
for _, entry := range handler.adminAuthThrottle.entries {
entry.blockedUntil = time.Now().Add(-time.Second)
}
handler.adminAuthThrottle.mu.Unlock()
if rec := request("admin-secret"); rec.Code != http.StatusOK {
t.Fatalf("correct key after expiry returned %d, want 200", rec.Code)
} else if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("admin response Cache-Control = %q, want no-store", got)
}
if rec := request("wrong-key"); rec.Code != http.StatusUnauthorized {
t.Fatalf("failure after successful login returned %d, want 401", rec.Code)
}
}

func TestAdminAuthThrottleBoundsTrackedIPs(t *testing.T) {
throttle := newAdminAuthThrottle()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < adminAuthMaxEntries+200; i++ {
throttle.recordFailure(fmt.Sprintf("198.51.100.%d", i), now)
}
if got := len(throttle.entries); got > adminAuthMaxEntries {
t.Fatalf("tracked IPs = %d, want <= %d", got, adminAuthMaxEntries)
}
}

func TestAdminAuthThrottleSaturationPreservesLiveEntries(t *testing.T) {
throttle := newAdminAuthThrottle()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < adminAuthMaxEntries; i++ {
ip := fmt.Sprintf("source-%d", i)
throttle.entries[ip] = &adminAuthThrottleEntry{windowStart: now, failures: adminAuthMaxFailures - 1}
}
throttle.recordFailure("source-0", now)
if retry := throttle.retryAfter("untracked", now.Add(time.Minute)); retry != 9*time.Minute {
t.Fatalf("saturated admission retry = %v, want 9m", retry)
}
if retry := throttle.recordFailure("untracked", now.Add(time.Minute)); retry == 0 {
t.Fatal("saturated failure tracking accepted a new source")
}
if retry := throttle.retryAfter("source-0", now.Add(time.Minute)); retry != 9*time.Minute {
t.Fatalf("live block was evicted: %v", retry)
}
if got := throttle.recordFailure("source-1", now.Add(time.Minute)); got != adminAuthBlockDuration {
t.Fatalf("live failure count was lost: %v", got)
}
if len(throttle.entries) != adminAuthMaxEntries {
t.Fatalf("table size = %d", len(throttle.entries))
}
if retry := throttle.retryAfter("untracked", now.Add(adminAuthFailureWindow)); retry != 0 {
t.Fatalf("expired slots did not recover: %v", retry)
}
if retry := throttle.retryAfter("source-1", now.Add(adminAuthFailureWindow)); retry != time.Minute {
t.Fatalf("later block must survive cleanup: %v", retry)
}
}

func TestAdminAuthMiddlewareRejectsUntrackedSourceWhenFull(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newSelfServiceHandler(t, false)
now := time.Now()
for i := 0; i < adminAuthMaxEntries; i++ {
handler.adminAuthThrottle.entries[fmt.Sprintf("source-%d", i)] = &adminAuthThrottleEntry{windowStart: now}
}
router := gin.New()
handler.RegisterRoutes(router)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", nil)
req.Header.Set("X-Admin-Key", "admin-secret")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" {
t.Fatalf("untracked valid candidate at capacity = %d %s, want 429 with Retry-After", rec.Code, rec.Body.String())
}
}
Loading
Loading