Skip to content

fix(auth): throttle clients behind a named proxy separately, and do not count expired tokens - #1035

Merged
Eomdahyeon merged 5 commits into
mainfrom
fix/issue-1031-throttle-trusted-proxy
Oct 5, 2026
Merged

Eomdahyeon merged 5 commits into
mainfrom
fix/issue-1031-throttle-trusted-proxy

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

Closes #1031

문제

인증 실패 스로틀은 클라이언트를 TCP peer 주소로 가린다. compose 가 싣는 리버스 프록시(Caddy) 뒤에서는 모든 사용자가 프록시의 주소로 도착해 한 버킷을 공유한다. 거기에 만료된 토큰도 401 이라 실패로 셌다. 그래서 사용자 몇 명의 평범한 토큰 만료만으로 한도에 닿고, 윈도가 지날 때까지 모두가 429 auth_throttled 를 받는다.

변경

1. 만료된 토큰은 세지 않는다. app.py 의 게이트가 principal.status_code == 401 and not principal.expired 일 때만 실패를 기록한다. expired 는 #1023 에서 AuthError 가 만들어지는 자리(jwt.ExpiredSignatureError)에서 세운 플래그다 — 문장을 읽지 않는다. 만료 토큰은 실패 기록을 지우지도 않는다(성공이 아니므로).

2. 이름을 댄 프록시 뒤에서만 X-Forwarded-For 를 읽는다. KPUBDATA_BUILDER_TRUSTED_PROXIES (주소·CIDR 블록, 콤마 구분, 기본 미설정).

  • TCP peer 가 그중 하나일 때에만 헤더를 읽는다. 아니면 peer 가 식별자다 — Builder 에 직접 닿은 요청은 헤더로 자기를 다르게 댈 수 없다.
  • 오른쪽부터 읽어 신뢰하는 프록시가 아닌 첫 주소를 클라이언트로 본다. 프록시는 자기가 본 주소를 덧붙이므로, 클라이언트가 직접 써 보낸 값은 그 왼쪽에 있고 닿지 않는다.
  • 헤더가 없거나, 전부 신뢰하는 프록시이거나, 읽다가 주소가 아닌 값을 만나면 peer 로 돌아간다.
  • 주소로 읽히지 않는 설정 항목은 경고 로그와 함께 버린다(기동은 실패하지 않는다; 남는 설정은 덜 신뢰하는 쪽이다). IPv4-mapped IPv6 peer(::ffff:a.b.c.d)는 IPv4 로 맞춘다.
  • 식별 로직은 auth_throttle.py 의 client_identity 한 곳에 있고 http.py 는 그것을 부른다.

3. 문서와 배선. docs/deploy.md 에 "리버스 프록시 뒤에서" 절, docs/deployment.md 표, .env.app.example. docker-compose.prod.app.yml 은 변수를 값 없이 통과만 시킨다.

운영 설정에 대해 — 정해 주셔야 하는 것

  • compose 에 기본값을 넣지 않았다. 설정하지 않으면 동작은 전과 같다(이슈의 세 번째 조건). 그래서 이 PR 만으로는 문서화된 기본 배포의 문제가 풀리지 않는다 — 누군가 값을 적어야 한다. 적을 값은 Caddy 컨테이너가 붙은 Docker 네트워크의 대역이고 배포마다 다를 수 있어 제가 고르지 않았다.
  • Cloudflare 가 앞에 있으면 Caddy 설정도 필요하다. .env.app.example 은 "Cloudflare proxied DNS → Caddy" 구성을 말한다. Caddy 는 기본적으로 앞단을 신뢰하지 않고 X-Forwarded-For 를 자기가 본 peer(Cloudflare 주소)로 쓴다. 그 상태에서는 Builder 가 사용자 대신 Cloudflare 엣지 주소로 묶는다. ops/caddy/Caddyfile 에는 trusted_proxies 가 없다. 이것은 문서에 적어 두기만 했고 Caddyfile 은 건드리지 않았다. (Caddy 의 이 기본 동작은 제 지식에 따른 것이고 이 저장소에서 프록시를 띄워 확인한 것은 아니다.)

알아 둘 것

만료된(서명이 유효했던) 토큰 하나를 가진 쪽은 이제 그것을 스로틀 없이 반복해 보낼 수 있고, 그때마다 서명 검증 한 번의 CPU 가 든다. 모듈 docstring 이 스로틀의 두 번째 목적으로 든 바로 그 비용이다. 이슈의 결정(만료는 세지 않는다)을 따랐고, 위조·무효 토큰은 여전히 센다.

테스트

tests/unit/test_auth_throttle_proxy.py (새 파일):

  • client_identity: 설정 없음 / peer 가 프록시가 아님 / 프록시가 알려 준 주소 / 클라이언트가 써 보낸 접두 무시 / 신뢰하는 프록시 여러 겹 / 쓸 수 없는 헤더 네 가지 / IPv4-mapped peer / peer 없음.
  • 설정 파싱과 환경변수 읽기.
  • 실제 소켓(테스트 클라이언트가 "프록시", peer 127.0.0.1): 한 프록시 뒤 두 클라이언트가 따로 스로틀됨 / 헤더 왼쪽을 바꿔도 새 버킷을 얻지 못함 / 설정이 없으면 헤더가 아무것도 바꾸지 않음.
  • 만료 토큰 10번에도 카운트가 움직이지 않음 / 서명이 틀린 토큰은 여전히 4번째에 429 / 만료 토큰이 섞여도 실제 실패 3번이 한도에 닿음.

test_prod_oidc_plumbing.py 의 compose 통과 목록에 새 변수를 더했다.

검증

  • pytest tests/unit/test_auth_throttle_proxy.py tests/unit/test_auth_throttle.py tests/unit/test_oidc_auth.py tests/unit/test_stable_error_codes.py → 117 passed.
  • pytest tests/unit -k "doc or env or compose or deploy" → 110 passed.
  • ruff check src tests, ruff format --check src tests, mypy src 통과. mkdocs build --strict 통과.
  • 전체 스위트는 로컬에서 돌리지 않았다 — CI 에 맡긴다. 실제 프록시 뒤에서 돌려 보지 않았다.

🤖 Generated with Claude Code

…ot count expired tokens

The failure throttle keyed on the TCP peer, which behind a reverse proxy is the
proxy for every user, and it counted every 401, an expired token included.
Ordinary expiry across users reached the limit and locked all of them out.

KPUBDATA_BUILDER_TRUSTED_PROXIES names the proxies whose X-Forwarded-For is
read, from the right, skipping trusted hops; without it the header is still
never read. An expired token verified and is only old, so it no longer moves
the count.

Closes #1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/kpubdata_builder/service/auth_throttle.py Fixed
CodeQL flagged the warning for writing the setting's text to the log. The value
is an address, but a variable set by mistake to something else would be logged
too. Log which entry was dropped instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/kpubdata_builder/service/auth_throttle.py Fixed
CodeQL still flagged the warning: it reads a variable whose name contains
'trusted' as sensitive, and the name constant was a log argument. Only the
entry's position is an argument now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diff 를 읽었고 CLEAN 이라 승인합니다.

  • X-Forwarded-For 를 TCP peer 가 이름을 댄 프록시일 때에만, 그리고 오른쪽부터 읽습니다. 클라이언트가 써 보낸 왼쪽 부분에는 닿지 않고, 실제 소켓 테스트가 "접두를 바꿔도 새 버킷을 얻지 못한다" 와 "설정이 없으면 헤더가 아무것도 바꾸지 않는다" 를 고정합니다.
  • 만료 토큰은 세지 않되 실패 기록을 지우지도 않습니다. 판정은 AuthError.expired 플래그로 하고 문장을 읽지 않습니다.
  • 잘못된 설정 항목은 값이 아니라 위치로만 로그에 남깁니다. code scanning 의 두 지적은 앞선 커밋에 달린 것으로 보이고 지금 체크는 통과합니다.

본문이 "정해 달라" 고 한 두 가지:

  • compose 에 기본값을 넣지 않은 것은 맞습니다. 대역은 배포마다 다르고, 틀린 기본값은 헤더를 아무나 믿게 만듭니다. 배포 대상이 정해지면 그때 .env 에 적습니다.
  • Cloudflare → Caddy 의 trusted_proxies 는 문서에 적어 둔 것으로 충분합니다. Caddyfile 은 배포 대상이 정해질 때 같이 봅니다.

알아 둘 것으로 적은 비용 — 만료된 토큰 하나를 가진 쪽이 서명 검증을 스로틀 없이 반복시킬 수 있다 — 은 받아들입니다. 그 토큰은 IdP 가 서명한 것이어야 하므로 실제 사용자였던 쪽으로 한정됩니다.

@Eomdahyeon
Eomdahyeon merged commit 3e4c34b into main Oct 5, 2026
23 checks passed
yeongseon pushed a commit that referenced this pull request Oct 6, 2026
…token (#1058)

## 무엇을

`OIDC_ISSUER` 가 설정되고 `KPUBDATA_BUILDER_API_KEY` 가 없는 배포(다중 사용자 프로필)에서,
토큰 없는 요청의 401 문구를 고칩니다.

- 전: `{"error": "api key not configured", "code": "unauthorized"}`
- 후: `{"error": "sign-in required: send a bearer token", "code":
"unauthorized"}`

## 왜

#1057 의 기동 스모크를 손으로 띄워 보다가 봤습니다. 그 배포에는 설정할 API 키가 없는데, 문구는 읽는 사람을 키 설정
쪽으로 보냅니다. `authenticate()` 가 Bearer 가 없으면 그대로 API 키 경로로 내려가기 때문입니다.

## 바뀌지 않는 것

- 상태 코드(401)와 `code`(`unauthorized`). Studio 는 `code` 로 분기합니다. 문구에 의존하는
곳은 Builder 계약·테스트·문서와 Studio `src` 에서 찾지 못했습니다(`grep "api key not
configured"`).
- OIDC 와 API 키를 함께 쓰는 배포: 키 경로가 그대로 판정합니다.
- OIDC 가 없는 배포: `api key not configured` 그대로.
- 인증 실패 집계(#1035)에는 손대지 않았습니다.

## 테스트

`tests/unit/test_oidc_auth.py::TestRequestWithoutAToken` 4건 — OIDC 전용에서
새 문구, API 키를 보내도 같은 답, 부정 2건(키도 받는 배포 / OIDC 없는 배포는 종전대로).

## 검증

- `test_oidc_auth.py`, `test_auth_throttle.py`, `test_signup_ledger.py`,
`test_ephemeral_credentials.py` 124건 통과
- `ruff`, `mypy src` 통과
- 전체 단위 스위트는 로컬에서 돌리지 않았습니다. CI 에 맡깁니다.

인증 응답의 문구를 바꾸는 것이라 직접 머지하지 않고 리뷰를 기다립니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): the failure throttle keys on the proxy's address and counts expired tokens

3 participants