Rails 토큰 인증 API에서 Bearer 접두사 검사하기

블로그에 글을 올리는 경로가 관리자 화면과 SSH 스크립트뿐이라, 외부 클라이언트가 쓸 수 있는 쓰기 API를 붙였다. 인증은 공유 토큰 하나를 Bearer 헤더로 받는 가장 단순한 형태로 정했다.

구현은 짧았지만 두 군데서 걸렸다. 하나는 상속할 베이스 클래스였고, 다른 하나는 테스트가 잡아준 인증 우회였다.

ApplicationController를 상속하면 안 된다

처음에는 기존 컨트롤러와 같은 부모를 쓰려고 했다. 그런데 이 앱의 ApplicationController에는 API 클라이언트를 막는 장치가 두 개 있다.

class ApplicationController < ActionController::Base
  include Authentication          # before_action :require_authentication
  allow_browser versions: :modern
end

Authentication은 세션 쿠키를 요구하니 토큰 인증과 충돌한다. allow_browser는 더 조용히 문제를 일으킨다. User-Agent를 보고 구형 브라우저를 걸러내는 기능인데, curl처럼 UA가 없거나 낯선 클라이언트도 함께 차단된다. 토큰이 맞아도 요청이 막힌다.

그래서 ActionController::API를 상속했다. 세션과 CSRF 미들웨어가 함께 빠지므로, 이 컨트롤러의 방어선은 토큰 검사 하나뿐이 된다는 뜻이기도 하다.

토큰 비교와 미설정 처리

토큰 비교는 == 대신 타이밍 공격에 안전한 비교를 쓴다. Active Support에는 두 가지가 있는데 선택이 갈린다.

fixed_length_secure_compare는 길이가 다르면 ArgumentError를 던진다. 길이가 가변인 사용자 입력에는 맞지 않는다. secure_compare는 내부에서 해시를 거쳐 길이가 달라도 안전하게 비교하므로 이쪽을 쓴다.

토큰이 서버에 설정되지 않은 경우도 정해야 한다. 빈 값끼리 비교해서 통과시키면 설정 누락이 곧 인증 해제가 된다. 값이 없으면 무조건 거부하도록 했다.

테스트가 잡은 Bearer 우회

인증의 실패 경로를 먼저 테스트로 적었다. 토큰 없음, 틀린 토큰, 서버 토큰 미설정, 그리고 Bearer 접두사 없이 토큰만 보낸 경우.

post api_v1_posts_path, params: { post: { title: "Bare" } },
  headers: { "Authorization" => TOKEN }, as: :json
assert_response :unauthorized

이 네 번째가 실패했다.

Expected response to be a <401: unauthorized>, but was a <201: Created>

원인은 첫 구현의 이 줄이다.

provided = request.authorization.to_s.delete_prefix("Bearer ")

delete_prefix는 해당 접두사가 있으면 떼고, 없으면 원본 문자열을 그대로 반환한다. 그래서 Authorization: <토큰>처럼 스킴 없이 보낸 헤더도 그대로 토큰이 되어 비교를 통과했다. 헤더 형식을 검사한다고 믿었지만 실제로는 아무것도 검사하지 않고 있었다.

접두사 존재를 명시적으로 검사한다

떼어내는 동작과 형식 검사를 분리했다.

BEARER_SCHEME = "Bearer "

def authenticate_api_token
  expected = Rails.configuration.x.blog_api_token.to_s
  authorization = request.authorization.to_s

  unless expected.present? &&
      authorization.start_with?(BEARER_SCHEME) &&
      ActiveSupport::SecurityUtils.secure_compare(
        authorization.delete_prefix(BEARER_SCHEME), expected
      )
    render json: { error: "Unauthorized" }, status: :unauthorized
  end
end

이 버그가 실제 피해로 이어질 확률은 높지 않다. 공격자가 유효한 토큰을 이미 알고 있어야 하고, 그렇다면 형식을 맞춰 보내면 그만이다. 그래도 고쳐야 하는 이유는 인증 코드가 의도한 계약과 실제 동작이 달랐기 때문이다. 이런 간극은 나중에 스킴별로 권한을 나누거나 다른 인증 방식을 추가할 때 조용히 구멍이 된다.

문자열을 다듬는 메서드는 대체로 실패하지 않는다. delete_prefix, chomp, sub은 대상이 없으면 원본을 돌려주지 성공 여부를 알려주지 않는다. 검증에 쓸 때는 다듬기 전에 형식을 따로 확인해야 한다. 인증처럼 실패 경로가 성공 경로보다 많은 코드는 그 목록을 테스트로 먼저 적어두는 편이 확실하다.

댓글

아직 댓글이 없습니다.

댓글 남기기