Loading the catalog…
Loading the catalog…
이음 프로젝트에는 학원 Wi-Fi에 연결된 직원만 출퇴근할 수 있도록 하는 근태 인증 기능 이 있다. 방식 자체는 단순했다. 학원에서 사용하는 네트워크의 공인 IP를 미리 등록해두고, 직원이 출근 또는 퇴근을 요청했을 때 현재 요청의 IP와 등록된 Wi-Fi IP를 비교하는 방식이다. 처음에는 Spring Boot에서 다음과 같이 요청 IP를 확인하면 충분하다고 생각했다. request.getRemoteAddr(); 개발 환경에서는 문제없이 동작했다. 하지만 실제 배포 환경에서는 상황이 달랐다. Browser │ ▼ Next Server │ ▼ Load Balancer / Proxy │ ▼ Spring Boot Spring Boot가 직접 사용자의 연결을 받는 구조가 아니었기 때문이다. 결국 문제는 단순히 "사용자의 IP를 어떻게 가져올까?" 가 아니었다. "여러 계층을 거쳐 전달된 IP를 Spring Boot가 어떤 조건에서 신뢰할 수 있을까?" 이번 글에서는 이 문제를 해결하기 위해 Next Server에서 전달된 Client IP를 Spring Boot가 그대로 사용하지 않고, HMAC Signature를 검증한 경우에만 출퇴근 판단에 사용하도록 변경한 과정 을 정리한다. 1. 처음에는 getRemoteAddr() 를 사용했다 초기 출퇴근 인증 흐름은 단순했다. 직원 출근 요청 │ ▼ Spring Boot │ ▼ request.getRemoteAddr() │ ▼ 등록된 Wi-Fi 공인 IP와 비교 │ ┌──┴──┐ │ │ 일치 불일치 │ │ 출근 거부 사용자의 요청 IP가 학원에 등록된 공인 IP와 같다면 출근을 허용하는 구조였다. 개발 환경에서는 이 방식이 정상적으로 동작했고, 처음에는 특별한 문제가 없어 보였다. 2. 운영에서는 Spring이 사용자를 직접 만나지 않았다 문제는 배포 환경이었다. 실제 서비스에서는 사용자의 요청이 바로 Spring Boot로 전달되는 것이 아니었다. 중간에 Next Server나 Load Balancer, Proxy 같은 계층이 존재했다. 사용자 │ ▼ Next / LB / Proxy │ ▼ Spring Boot 이런 구조에서 request.getRemoteAddr() 가 반환하는 값은 반드시 사용자의 네트워크 IP라고 볼 수 없다. Spring Boot 입장에서 getRemoteAddr() 는 자신과 직접 연결된 상대의 주소 를 반환하기 때문이다. 예를 들어 Spring이 다음 주소를 확인했다고 하자. 10.0.2.15 이 값이 사용자의 공인 IP가 아니라 Spring 바로 앞에 있는 Proxy의 주소라면 학원 Wi-Fi 접속 여부를 판단하는 기준으로 사용할 수 없다. 즉, 개발 환경 Client │ ▼ Spring Boot 에서는 문제가 드러나지 않았지만, 운영 환경 Client │ ▼ Next / Proxy │ ▼ Spring Boot 에서는 네트워크 구조가 달라지면서 같은 코드가 다른 의미의 IP를 바라보게 된 것이다. 3. Proxy IP를 등록하면 해결될까? Spring이 확인하는 Proxy IP를 학원 Wi-Fi IP로 등록하면 해결할 수 있을까? 그렇게 하면 출퇴근 인증의 의미 자체가 사라진다. 예를 들어 모든 사용자의 요청이 동일한 Proxy를 통과한다고 하자. 사용자 A ─┐ 사용자 B ─┼─→ Proxy ─→ Spring Boot 사용자 C ─┘ Spring 입장에서는 사용자의 실제 네트워크와 관계없이 모두 같은 Proxy에서 들어오는 요청처럼 보일 수 있다. 이 Proxy IP를 학원 Wi-Fi IP로 등록하면 다음과 같은 문제가 생긴다. 학원 내부 사용자 → Proxy IP → 출근 가능 학원 외부 사용자 → Proxy IP → 출근 가능 결국 "학원 네트워크에 연결된 직원만 출근할 수 있다." 라는 정책 자체를 검증할 수 없게 된다. 4. X-Forwarded-For 만 읽으면 되는 것 아닐까? Proxy 환경에서 원래 Client IP를 전달할 때 흔히 사용하는 값 중 하나가 X-Forwarded-For 다. X-Forwarded-For: 203.0.113.10 처음에는 Spring Boot에서 이 Header를 읽어 사용하면 해결할 수 있을 것처럼 보인다. 하지만 중요한 것은 Header의 이름이 아니라 그 값을 누가 만들었고 왜 신뢰할 수 있는가 였다. X-Forwarded-For 자체를 사용할 수 없는 것은 아니다. 신뢰할 수 있는 Reverse Proxy가 외부에서 전달된 값을 제거하거나 재작성하고 Client IP를 관리하는 구조라면 해당 Header를 신뢰하도록 구성할 수도 있다. 하지만 Client가 임의로 전달할 수 있는 Header 값을 애플리케이션에서 그대로 사용해서는 안 된다. 예를 들어 출퇴근 API가 다음 값을 아무런 검증 없이 사용한다고 가정해보자. X-Forwarded-For: 학원_공인_IP 클라이언트가 해당 값을 직접 지정할 수 있다면 학원 외부에서도 등록된 Wi-Fi IP를 넣어 요청할 가능성을 고려해야 한다. 결국 백엔드에서 확인해야 할 것은 단순히 "IP 값이 등록된 값과 일치하는가?" 만이 아니었다. "이 IP가 백엔드가 신뢰하도록 정한 서버를 거쳐 전달된 값인가?" 까지 확인할 필요가 있었다. 5. 문제를 다시 정의했다 처음에는 문제를 다음과 같이 생각했다. 사용자의 공인 IP를 Spring Boot까지 전달한다. 하지만 운영 구조를 확인하면서 문제의 초점을 바꿨다. 전달된 Client IP를 Spring Boot가 어떤 조건에서 신뢰할 것인지 결정한다. 즉, IP를 가져오는 문제에서 전달된 IP의 출처를 검증하는 문제 로 다시 정의했다. 이번 개선에서는 다음 세 가지를 목표로 했다. Proxy가 존재하는 운영 환경에서도 Client IP를 출퇴근 판단에 사용할 수 있어야 한다. 전달된 IP 값을 Spring Boot가 아무런 검증 없이 신뢰하지 않아야 한다. 운영 환경의 보안 설정이 누락됐을 때 검증이 자동으로 우회되어서는 안 된다. 여기서 한 가지 범위를 명확하게 할 필요가 있다. 이번 글에서는 Next Server가 Client IP를 최초로 어떤 방식으로 식별하는지 자체는 다루지 않는다. 해당 부분은 프론트엔드의 Next Server 구현 영역이며, 백엔드에서는 Next Server에서 전달된 IP와 요청 정보를 어떤 조건에서 신뢰할 것인지 에 초점을 맞췄다. 이를 위해 Next Server와 Spring Boot 사이에 HMAC 기반 검증 구조 를 적용했다. 6. IP와 함께 Signature를 전달하도록 변경했다 최종적인 흐름은 다음과 같다. Browser │ ▼ Next Server │ ├─ Client IP 전달 │ └─ HMAC Signature 생성 │ ▼ Spring Boot │ ├─ Timestamp 검증 ├─ 요청 정보 검증 ├─ HMAC Signature 검증 └─ IP 형식 검증 │ ▼ 검증된 Client IP │ ▼ 등록된 Wi-Fi IP와 비교 │ ▼ 출퇴근 판단 Next Server는 Client IP만 전달하지 않는다. 해당 요청에 대한 Signature도 함께 생성한다. Spring Boot 역시 전달받은 Client IP를 즉시 출퇴근 판단에 사용하지 않는다. 먼저 요청과 함께 전달된 Signature를 검증하고, 검증에 성공한 경우에만 해당 IP를 이후 로직에서 사용하도록 했다. 백엔드 입장에서 검증 경계는 다음과 같다. Browser │ ▼ Next Server │ │ Client IP │ Timestamp │ Signature ▼ ────── Backend Verification Boundary ────── Spring Boot │ ├─ 필수 값 확인 ├─ Timestamp 검증 ├─ Method / Path 확인 ├─ HMAC Signature 검증 └─ IP 형식 검증 │ ▼ 검증된 Client IP │ ▼ Wi-Fi IP 비교 7. 무엇을 서명해야 할까? Client IP 하나만 서명하는 방식도 생각할 수 있다. CLIENT_IP 하지만 IP에만 Signature가 묶여 있으면 동일한 값을 다른 요청에서도 사용할 수 있는 범위가 커진다. 그래서 서명 대상에 다음 정보를 함께 포함했다. METHOD + PATH + CLIENT_IP + TIMESTAMP 예를 들어 출근 요청이라면 개념적으로 다음과 같은 데이터가 된다. POST + /api/attendance/check-ins + 203.0.113.10 + 1720000000 이 데이터를 공유 Secret을 이용해 HMAC Signature로 만든다. METHOD PATH CLIENT_IP TIMESTAMP │ ▼ HMAC Secret │ ▼ Signature 각 값을 포함한 데에는 이유가 있었다. 8. METHOD 와 PATH 를 포함한 이유 IP에만 Signature가 묶여 있다면 한 API에서 생성된 값을 다른 API에서도 사용할 수 있는 범위가 넓어진다. 예를 들어 현재 IP를 확인하는 API가 있다고 하자. GET /api/attendance/wifi-ips/current 그리고 실제 출근 API는 다음과 같다. POST /api/attendance/check-ins Signature가 Client IP에만 의존한다면 요청의 목적과 Signature 사이의 연결이 약하다. 그래서 METHOD 와 PATH 까지 서명 대상에 포함했다. GET + /api/attendance/wifi-ips/current + CLIENT_IP 와 POST + /api/attendance/check-ins + CLIENT_IP 는 서로 다른 Signature가 생성된다. 즉, Signature를 단순한 IP 값이 아니라 특정 요청 정보와 함께 묶은 것 이다. 9. Timestamp로 Signature의 유효 시간을 제한했다 Method와 Path를 포함하더라도 정상적인 요청과 Signature가 오래 보관된 뒤 다시 사용되는 상황은 고려해야 했다. 그래서 Timestamp도 Signature 대상에 포함했다. METHOD + PATH + CLIENT_IP + TIMESTAMP Spring Boot에서는 전달된 Timestamp가 현재 시간을 기준으로 60초 이내인지 확인 한다. 요청 수신 │ ▼ Timestamp 확인 │ ├─ 60초 이내 │ │ │ ▼ │ 검증 계속 │ └─ 60초 초과 │ ▼ 403 여기서 Timestamp의 역할을 정확하게 구분할 필요가 있다. Timestamp를 추가했다고 해서 동일한 요청의 재사용 자체를 완전히 차단하는 것은 아니다. 동일한 Timestamp와 Signature가 유효 시간 안에 다시 전달된다면 Timestamp 검증만으로 두 요청을 구분할 수 없기 때문이다. 따라서 이번 구현에서 Timestamp는 Replay를 완전히 제거하는 장치 라기보다 오래된 Signature가 재사용될 수 있는 시간을 제한하는 장치 로 사용했다. 10. Spring Boot에서는 무엇을 검증할까? Spring Boot가 요청을 받으면 전달된 Client IP를 바로 사용하지 않는다. 먼저 다음 검증 과정을 거친다. Request │ ▼ 필수 Header 확인 │ ▼ Timestamp 확인 │ ▼ METHOD 확인 │ ▼ PATH 확인 │ ▼ CLIENT_IP 확인 │ ▼ HMAC Signature 재계산 │ ▼ 전달받은 Signature와 비교 │ ▼ 검증된 Client IP 사용 Spring Boot에서도 동일한 규칙과 Secret을 이용해 Signature를 다시 계산한다. METHOD + PATH + CLIENT_IP + TIMESTAMP 전달받은 Signature와 서버에서 계산한 Signature가 일치해야만 Client IP를 이후 출퇴근 판단에 사용한다. 즉, 단순히 Client IP = 203.0.113.10 이라는 Header가 존재한다는 이유만으로 값을 신뢰하지 않는다. 백엔드는 해당 요청 정보와 Client IP가 공유 Secret을 이용해 생성된 Signature와 일치하는가? 를 확인한 뒤 값을 사용한다. 11. Signature 비교에도 일반 문자열 비교를 사용하지 않았다 Signature를 비교할 때는 일반 문자열 비교 대신 상수 시간 비교(Constant-time comparison) 를 사용했다. 일반적인 문자열 비교는 구현에 따라 어느 위치에서 값이 달라졌는지에 따라 비교 시간이 달라질 수 있다. Signature와 같이 보안 검증에 사용하는 값에서는 이러한 시간 차이를 통한 정보 노출 가능성도 줄이는 편이 안전하다고 판단했다. 개념적인 흐름은 다음과 같다. Expected Signature │ ▼ Constant-time Compare ▲ │ Received Signature Signature 값의 일치 여부만 판단하고, 비교 과정에서 불필요한 정보를 드러내지 않도록 했다. 12. 검증 실패 이유를 외부에 세분화하지 않았다 Signature 검증은 여러 이유로 실패할 수 있다. Signature 누락 Timestamp 만료 잘못된 Signature IP 형식 오류 요청 정보 불일치 하지만 외부 응답에서는 각 실패 원인을 세분화하지 않았다. 모두 동일하게 403 Forbidden 으로 처리했다. Signature 누락 ──┐ Timestamp 만료 ──┤ Signature 오류 ──┼──→ 403 Forbidden IP 형식 오류 ────┤ 요청 불일치 ─────┘ 클라이언트가 검증 과정 내부의 세부 조건을 불필요하게 알 필요가 없다고 판단했기 때문이다. 내부에서는 필요한 로그를 남기더라도 외부 응답에서는 검증 구조에 대한 정보를 최소화했다. 13. Signature는 Header 전송에 맞게 처리했다 Signature는 HTTP Header를 통해 전달해야 했다. 이를 위해 Signature는 Base64 URL-safe 형식 으로 처리했다. Next Server에서 Spring Boot로 전달되는 검증 정보는 개념적으로 다음과 같다. Client IP Timestamp Signature Spring Boot는 이 값과 현재 요청의 Method, Path를 이용해 동일한 Signature를 계산한다. 중요한 것은 Header가 존재한다는 사실이 아니라, 해당 요청 정보와 전달된 Client IP가 Signature와 일치하는지 를 검증하는 것이다. 14. 모든 API에 Signature 검증을 적용하지는 않았다 Client IP가 실제 비즈니스 판단에 사용되는 API에만 동일한 검증 방식을 적용했다. Signature 검증이 필요한 API는 다음과 같다. 현재 공인 IP 조회 GET /api/attendance/wifi-ips/current 현재 접속 IP를 사용하므로 검증이 필요하다. Wi-Fi IP 등록 POST /api/attendance/wifi-ips 현재 네트워크 IP가 등록 과정에 사용되므로 검증이 필요하다. 출근 POST /api/attendance/check-ins 현재 Client IP가 출근 가능 여부를 결정하므로 검증이 필요하다. 퇴근 POST /api/attendance/check-outs 현재 Client IP가 퇴근 가능 여부를 결정하므로 검증이 필요하다. 반대로 현재 접속한 Client IP가 비즈니스 판단에 사용되지 않는 API에는 동일한 검증을 강제하지 않았다. GET /api/attendance/wifi-ips DELETE /api/attendance/wifi-ips/... 즉, 모든 요청에 일괄적으로 적용하기보다 Client IP가 실제 비즈니스 판단에 사용되는 API에만 검증을 적용한다. 는 기준을 사용했다. 15. 로컬 개발 환경까지 같은 구조를 강제하지 않았다 운영에서는 요청이 Next Server를 거쳐 Spring Boot로 전달되기 때문에 Signature 검증이 필요했다. 하지만 로컬에서 백엔드 기능을 개발할 때까지 반드시 Next Server를 실행해야 한다면 개발 과정이 불편해진다. 그래서 Local과 Production의 IP 확인 전략을 분리했다. Local 로컬 환경에서는 기존 방식인 getRemoteAddr() 를 사용한다. Local Client │ ▼ Spring Boot │ ▼ getRemoteAddr() Next Server 없이도 백엔드 기능을 개발할 수 있도록 했다. Production 운영 환경에서는 Next Server에서 전달된 정보와 Signature를 검증한다. Browser │ ▼ Next Server │ ▼ Signed Request │ ▼ Spring Boot 즉, Local → 개발 편의성을 위한 직접 IP 확인 Production → Signature가 검증된 Client IP 사용 으로 환경별 책임을 나눴다. 16. Secret이 없다고 검증을 생략하지 않았다 HMAC 검증에서 중요한 값은 Next Server와 Spring Boot가 공유하는 Secret이다. 그런데 운영 환경에서 Secret 설정을 누락했다고 가정해보자. 다음과 같이 구현하면 문제가 생긴다. Secret 없음 │ ▼ 검증 생략 │ ▼ 요청 허용 환경 변수 하나를 잘못 설정한 것만으로 전체 IP 검증이 사라질 수 있다. 그래서 Production 환경에서는 필요한 Secret이 존재하지 않을 경우 검증을 비활성화하는 대신 애플리케이션 기동 자체가 실패하도록 했다. Application Start │ ▼ Secret 확인 │ ┌──┴──┐ │ │ 있음 없음 │ │ 실행 기동 실패 잘못된 보안 설정을 가진 상태로 서비스가 실행되는 것보다 서비스가 시작되지 않는 쪽을 선택했다. 즉, Fail Open 설정 오류 → 검증 생략 → 서비스 실행 이 아니라 Fail Closed 설정 오류 → 서비스 기동 실패 방향으로 구성했다. 17. 실제로 검증이 동작하는지 테스트했다 검증 구조를 구현하는 것에서 끝내지 않고, 정상적인 요청과 변조된 요청을 Spring Boot가 의도한 대로 구분하는지 테스트했다. 특히 다음 네 가지 경우를 확인했다. 정상 Signature Client IP 변조 Timestamp 만료 Signature 변조 17-1. 정상 Signature 요청 먼저 정상적인 Client IP와 현재 Timestamp를 기준으로 올바른 Signature를 생성했다. 해당 요청을 ClientIpResolver 에 전달했을 때 검증에 성공하고 원래 Client IP가 반환되는지 확인했다. 테스트에서는 Next Proxy 검증을 활성화하고, 현재 Timestamp와 정상적으로 생성된 Signature가 포함된 요청을 전달했다. 검증 결과는 다음 조건을 만족해야 한다. 정상 Client IP + 유효한 Timestamp + 올바른 Signature │ ▼ 검증 성공 │ ▼ Client IP 반환 테스트가 정상적으로 통과하는 것을 확인했다. 17-2. Client IP만 변조했을 때 다음으로 Signature를 생성한 이후 Client IP 값만 변경한 요청 을 테스트했다. 예를 들어 Signature가 다음 Client IP를 기준으로 생성됐다고 하자. 203.0.113.10 그 이후 요청에 포함된 Client IP만 다른 값으로 변경한다. Signature 생성 시 IP 203.0.113.10 ↓ IP만 변경 요청에 전달된 IP 203.0.113.20 Signature는 기존 IP를 기준으로 만들어졌기 때문에 Spring Boot에서 변경된 IP를 기준으로 HMAC을 다시 계산하면 값이 일치하지 않는다. 정상 Signature + 변조된 Client IP │ ▼ HMAC 재계산 │ ▼ Signature 불일치 │ ▼ ForbiddenException 이 테스트를 통해 요청에 포함된 Client IP 값이 변경되더라도 기존 Signature를 그대로 이용해 검증을 통과할 수 없음을 확인했다. 이 부분은 이번 구현에서 특히 중요했다. 백엔드가 X-Client-IP에 값이 있으니 사용한다. 가 아니라 X-Client-IP 값이 Signature 생성에 사용된 값과 일치하는지 검증한 뒤 사용한다. 는 것을 실제 테스트로 확인했기 때문이다. 17-3. Timestamp가 만료됐을 때 다음으로 유효 시간이 지난 Signature를 검증했다. 현재 시각보다 61초 오래된 Timestamp 를 이용해 Signature를 생성하고 요청을 전달했다. 현재 시각 │ │ 61초 차이 ▼ 요청 Timestamp Signature 자체는 해당 Timestamp에 맞게 정상적으로 생성되어 있더
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
[이음] `getRemoteAddr()`는 왜 운영에서 틀렸을까? HMAC으로 공인 IP를 검증하기. 이음 프로젝트에는 학원 Wi-Fi에 연결된 직원만 출퇴근할 수 있도록 하는 근태 인증 기능 이 있다. 방식 자체는 단순했다. 학원에서 사용하는 네트워크의 공인 IP를 미리 등록해두고, 직원이 출근 또는 퇴근을 요청했을 때 현재 요청의 IP와 등록된 Wi-Fi IP를 비교하는 방식이다. 처음에는 Spring Boot에서 다음과 같이 요청 IP를 확인하면 충분하다고 생각했다. request.getRemoteAddr(); 개발 환경에서는 문제없이 동작했다. 하지만 실제 배포 환경에서는 상황이 달랐다. Browser │ ▼ Next Server │ ▼ Load Balancer / Proxy │ ▼ Spring…
Open source