Skip to content

fix(privacy): 레이트리밋 키의 평문 IP 제거 — 방침과 실제를 맞춘다 - #194

Open
seizeh wants to merge 1 commit into
mainfrom
fix/hash-ip-in-ratelimit-keys
Open

seizeh wants to merge 1 commit into
mainfrom
fix/hash-ip-in-ratelimit-keys

Conversation

@seizeh

@seizeh seizeh commented Aug 22, 2026

Copy link
Copy Markdown
Owner

개인정보처리방침 "이용 과정에서 자동 생성" 항목은 단서 없이 이렇게 적고 있습니다:

IP 주소(SHA-256 해시 후 저장)

app.auth_logs 는 그 약속을 지켰는데(record_auth_logsha256Hex 로 넣습니다) 레이트리밋 버킷만 평문이었습니다:

login:ip:139.178.129.10:29787996      ← app.rate_limits.bucket

한쪽에서 가리는 값을 다른 쪽에서 그대로 쓰면 가린 의미가 없습니다. 실제로 이 키를 같은 시각의 login:user:<아이디> 버킷과 맞춰 특정인의 접속 IP를 복원할 수 있었습니다 — 2026-08-22 로그인 조사에서 그렇게 찾았고, 그 과정에서 이 문제가 드러났습니다.

법적 판단이 아니라 자기 정합성 문제로 봅니다. IP를 레이트리밋에 쓰는 것 자체는 통상적이고, 문제는 "안 한다고 적어 놓고 했다" 쪽입니다.

바꾼 것

clientIp()clientIpKey(). 함수가 해싱해서 내보냅니다.

원본을 돌려주고 "호출부가 알아서 해싱"으로 두지 않은 이유는, 그러면 다음에 추가되는 호출부가 같은 실수를 반복하기 때문입니다. 이 파일이 고쳐 온 오류들이 전부 그 모양이었습니다(폴백 잔존 · 주석 오판). 원본을 만질 수 있는 자리를 없앱니다.

평문이 새던 곳 전수 7곳:

종류 위치
버킷 키 6 login:ip · refresh:ip · pwreset:ip · sms:ip · lite:ip · otpverify:ip
로그 1 send-phone-codeconsole.error(…, { purpose, ip })

마지막 것은 이번에 처음 봤습니다 — 버킷만 보다가 놓칠 뻔했습니다. 로그도 저장입니다. 해시 앞 12자만 남깁니다.

auth_logs같은 sha256Hex 를 씁니다. 덕분에:

  • record_auth_log 는 이 값을 그대로 넘깁니다 → 해싱이 한 번으로 줍니다
  • 기존 auth_logs 행과 값이 이어집니다 → 마이그레이션 불필요
  • 두 곳을 맞대 볼 수 있습니다(조사용으로는 오히려 편해집니다)

하지 않은 것

record_client_error 는 이미 'cerr:ip:' || md5(v_ip)해싱하고 있습니다 — 평문이 아니라 건드리지 않았습니다.

md5라 방침의 'SHA-256'과 알고리즘이 다르긴 한데, 그건 표기 정합 문제지 평문 노출이 아니고 고치려면 운영 DDL + 스냅샷 재생성이 따라옵니다. 이 PR은 DB 변경 0으로 두는 게 검토·되돌리기에 낫다고 봤습니다. 별건으로 남깁니다.

곁가지 — deno check 래칫이 0이 됐습니다

auth_test.tsauth.ts 를 임포트하면서 기존 타입 오류 1건이 테스트 단계로 딸려 들어왔습니다(지금까지는 어느 테스트도 auth.ts 를 쓰지 않아 드러나지 않았습니다).

b64urlToBytes 의 반환 타입을 Uint8Array<ArrayBuffer> 로 좁혀 해소했습니다 — new Uint8Array(n) 은 실제로 항상 ArrayBuffer 라 런타임 동작은 그대로입니다.

그래서 db-tests.ymlMAX1 → 0 으로 내립니다. 0은 래칫의 종착점이라 이제부터는 새 오류가 곧 실패입니다(0032 §7.8이 지적한 "1건을 0으로 세던" 결함과 같은 자리를 아예 없앱니다).

검증

deno check functions/*/index.ts   오류 0건 (종전 1건)
deno test  _shared/*_test.ts      21 passed (auth 신규 9 포함)

auth_test.ts 가 못 박은 것:

  • 반환값에 원본 IP가 없을 것 (옥텟 조각도 — 이 단언이 파일의 존재 이유)
  • auth_logs 와 같은 해시일 것
  • 결정적일 것 / 같은 IP는 같은 키, 다른 IP는 다른 키
  • 헤더 없으면 null (폴백 금지 — 폴백이 곧 우회로)
  • x-forwarded-for · x-real-ip 는 쳐다보지 않을 것
  • 버킷 키에 박아도 평문이 남지 않을 것(실사용 형태)

영향

버킷 키 문자열이 바뀌므로 기존 카운터가 리셋됩니다. 창이 60초~1시간이고 app.rate_limits 는 현재 0행이라 실질 영향은 없습니다.

한계 (정직하게)

이건 익명화가 아니라 가명화입니다. IPv4는 43억 개뿐이라 무염 해시는 전수 대입으로 되돌릴 수 있습니다. 목적은 방침이 약속한 수준(SHA-256)에 맞추는 것이고, 더 센 보장이 필요하면 방침과 함께 바꿔야 합니다(솔트·절단 등). 주석에 그대로 적어 뒀습니다.

백업(scripts/backup.shpublic·app 전체를 덤프)에 이미 들어간 과거 평문은 이 PR로 지워지지 않습니다 — 백업 보관기간이 지나야 사라집니다.

🤖 Generated with Claude Code

개인정보처리방침 "이용 과정에서 자동 생성" 항목은 단서 없이 이렇게 적고 있다:

  IP 주소(SHA-256 해시 후 저장)

app.auth_logs 는 그 약속을 지켰는데(record_auth_log 가 sha256Hex 로 넣는다)
레이트리밋 버킷만 평문이었다:

  login:ip:139.178.129.10:29787996      ← app.rate_limits.bucket

한쪽에서 가리는 값을 다른 쪽에서 그대로 쓰면 가린 의미가 없다. 실제로 이 키를
같은 시각의 `login:user:<아이디>` 버킷과 맞춰 특정인의 접속 IP 를 복원할 수
있었다 — 2026-08-22 로그인 조사에서 그렇게 찾았고, 그 과정에서 이 문제가 드러났다.

## 무엇을 바꿨나

clientIp() → clientIpKey() 로 바꾸고 **함수가 해싱해서 내보낸다.**

원본을 돌려주고 "호출부가 알아서 해싱" 으로 두지 않은 이유는, 그러면 다음에
추가되는 호출부가 같은 실수를 반복하기 때문이다. 이 파일이 고쳐 온 오류들이
전부 그 모양이었다(폴백 잔존·주석 오판). 원본을 만질 수 있는 자리를 없앤다.

평문이 새던 곳 전수(7):
  login:ip · refresh:ip · pwreset:ip · sms:ip · lite:ip · otpverify:ip  (버킷 6)
  send-phone-code 의 `console.error(…, { purpose, ip })`               (로그 1)

마지막 것은 이번에 처음 봤다 — 버킷만 보다가 놓칠 뻔했다. **로그도 저장이다.**
해시 앞 12자만 남긴다(같은 출처인지 대조하기엔 충분하다).

auth_logs 와 **같은 sha256Hex** 를 쓴다. 덕분에:
  · record_auth_log 는 이 값을 그대로 넘긴다 — 해싱이 한 번으로 준다
  · 기존 auth_logs 행과 값이 이어진다(마이그레이션 불필요)
  · 두 곳을 맞대 볼 수 있다

## 하지 않은 것

record_client_error 는 이미 `'cerr:ip:' || md5(v_ip)` 로 해싱하고 있다 — 평문이
아니므로 건드리지 않았다. md5 라 방침의 'SHA-256' 과 알고리즘이 다르긴 한데,
그건 표기 정합 문제지 평문 노출이 아니고 고치려면 운영 DDL + 스냅샷 재생성이
따라온다. 이 PR 은 **DB 변경 0** 으로 두는 게 검토·되돌리기에 낫다. 별건으로 남긴다.

## 곁가지 — deno check 래칫이 0 이 됐다

새 auth_test.ts 가 auth.ts 를 임포트하면서 **기존 타입 오류 1건이 테스트 단계로
딸려 들어왔다**(지금까지는 어느 테스트도 auth.ts 를 쓰지 않아 드러나지 않았다).
b64urlToBytes 의 반환 타입을 Uint8Array<ArrayBuffer> 로 좁혀 해소했다 —
new Uint8Array(n) 은 실제로 항상 ArrayBuffer 라 런타임 동작은 그대로다.

그래서 db-tests.yml 의 MAX 를 1 → 0 으로 내린다. 0 은 래칫의 종착점이라
이제부터는 새 오류가 곧 실패다(0032 §7.8 이 지적한 "1건을 0으로 세던" 결함과
같은 자리를 아예 없앤다).

## 검증

  deno check functions/*/index.ts   오류 0건 (종전 1건)
  deno test  _shared/*_test.ts      21 passed (auth 신규 9 포함)

auth_test.ts 가 못 박은 것 — 반환값에 원본 IP 가 없을 것, auth_logs 와 같은
해시일 것, 결정적일 것, 헤더 없으면 null(폴백 금지), XFF/x-real-ip 무시.

## 영향

버킷 키 문자열이 바뀌므로 **기존 카운터가 리셋된다.** 창이 60초~1시간이고
app.rate_limits 는 현재 0행이라 실질 영향은 없다.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant