서버 측 sync_sso가 base64에 `+`가 포함되면(동기화 필드에 `~`가 있을 때 발생) 유효한 페이로드를 422 "Login Error"로 거부함

여러분 안녕하세요~

b64 인코딩된 값에 '+'가 포함되어 있을 때 sync_sso 엔드포인트에서만 문제가 발생하는 다소 생소한 버그를 발견했습니다. 통합 문제(Integration issue)가 아니라는 점에 확신이 있습니다. Claude Fable을 사용하여 버그를 진단하고 보고서를 생성했는데, 이것이 괜찮을지 모르겠습니다. 모든 내용을 검토하고, 직접 재현했으며, 근본 원인과 수정 방안에 동의합니다. 다른 것이 필요하시다면 알려주세요. 이제 제게는 더 이상 매우 긴급하지 않습니다. 우회 방법을 찾았기 때문이죠. 하지만 다른 사람들이 부딪힐 수 있는 다소 특이한 문제입니다.

수고 많으셨습니다!
Brandon

요약

POST /admin/users/ssync_ssosso 파라미터를 두 번 디코딩합니다. base64 인코딩된 페이로드에 + 문자가 포함되면, 엔드포인트는 이를 공백으로 손상시켜 페이로드와 서명이 정확함에도 불구하고 일반적인 422 Login Error로 요청을 거부합니다.

ASCII(URL 인코딩) 페이로드 콘텐츠의 경우, base64는 ~(0x7E) 문자가 올바른 바이트 정렬 위치에 있을 때만 +를 생성합니다 — 그리고 RFC 3986에 따라 ~를 이스케이프하지 않는 URL 인코딩을 사용하는 클라이언트(예: Python ≥ 3.7, JavaScript의 encodeURIComponent)에 한해서만 그렇습니다.
실제로는 다음과 같이 나타납니다: 모든 사용자에게 동기화가 작동하지만, 프로필의 나머지 부분이 정렬을 어떻게 변경시키느냐에 따라 때때로, 그리고 바이오/사용자 이름 등에 ~가 있는 사용자에게만 문제가 발생합니다. 이는 사용자별 HMAC 신비처럼 보이며 외부에서 진단하기가 거의 불가능합니다. 이는 아마도 다른 사이트보다 제 사이트의 포럼에 더 큰 영향을 미칠 것입니다. 한국인과 한국어를 배우는 사람들은 바이오 끝에 '~'를 자주 사용하기 때문입니다.

브라우저 로그인 및 동기화의 경우 이 문제는 발생하지 않습니다(아래 참조). 이는 서버 측 sync_sso 호출에서만 문제가 됩니다.

근본 원인

Admin::UsersController#sync_sso (users_controller.rb#L500)에서:

sso = DiscourseConnect.parse("sso=#{params[:sso]}&sig=#{params[:sig]}", server_session:)

params[:sso]는 이미 Rack/Rails에 의해 폼 디코딩(form-decoded)되었지만, 재이스케이프(re-escaping) 없이 쿼리 문자열 문법으로 보간(interpolated)됩니다. DiscourseConnect.parse는 그 위에 Rack::Utils.parse_query를 실행합니다 (discourse_connect_base.rb#L93) — 이는 한 번만 인코딩된 값에 대한 두 번째 디코딩입니다. parse_query는 리터럴 +를 공백으로 변환하고, 아래 base64 문자 체크는 PayloadParseError를 발생시켜 422 "Login Error"로 표면화됩니다. (그 체크가 #26140에서 추가되기 전에는, 동일한 손상이 서명 비교에서 실패했습니다 — 결과는 동일했습니다.)

+는 폼 디코딩에 의해 변형되는 유일한 base64 알파벳 문자이므로, 이중 디코딩은 대부분의 페이로드에 대해 조용한 no-op(무의미한 작업)입니다 — 이것이 이 버그가 오랫동안 발견되지 않은 이유입니다. 브라우저 로그인 플로우(session/sso_login)에는 영향이 없습니다: 이는 원시 쿼리 문자열을 파싱하므로, 정확히 한 번만 디코딩합니다.

이 보간은 fb750af8e29a20bbbf4b70b0f3f4e4a06f602b69 (2014년 10월, “trivial update to allow api endpoint for sync_sso”)에 거슬러 올라가며, 원래 — 그리고 정확한 — DiscourseSingleSignOn.parse(request.query_string)를 대체했습니다.

이것은 통합 개발자의 인코딩 실수가 아닙니다: 공식 sync_sso 가이드sso 값을 단일 표준 폼 인코딩으로 전송하도록 보여주며, 공식 discourse_api gem도 마찬가지입니다 — 따라서 지침을 정확히 따르는 클라이언트는 base64에 +가 포함될 때마다 이 문제에 부딪힙니다.

재현 단계 (결정적)

  1. ~~~(세 개의 연속 틸드. 이는 바이트 위치 ≡ 2 mod 3에 하나를 배치하여 base64에 +를 강제하는 것을 보장)를 포함하는 필드 값을 가진 모든 사용자를 위한 유효한 서명된 sync_sso 페이로드를 구성합니다.

    Ruby에서 재현하는 것에 대한 참고: Discourse의 자체 SingleSignOn 클래스(또는 discourse_api gem)로 구성한 페이로드는 base64에 +를 포함할 수 없습니다 — Rack::Utils.build_query~%7E로 이스케이프하며, 그 출력 알파벳의 어떤 바이트도 base64 +에 매핑되지 않습니다. 재현하려면, 필드 값에 리터럴 ~~~를 포함하여 내부 쿼리 문자열을 수동으로 구성해야 합니다(알 수 없는 키는 무시됨), 예: Base64.strict_encode64("external_id=1&email=user@example.com&username=someuser&filler=~~~"), 서명하고, POST합니다. 이것이 기존 스펙이 버그를 포착하지 못한 이유이기도 합니다: Discourse의 자체 도구로는 트리거 페이로드를 생성할 수 없도록 구조화되어 있습니다.

  2. sso/sig를 올바르게 인코딩된 폼 파라미터로(+는 와이어에서 %2B로 전송) POST /admin/users/sync_sso를 호출합니다.

  3. 응답: 422 {"failed":"FAILED","message":"Login Error"}.

  4. 틸드를 xxx로 대체한 동일한 페이로드(base64에 이제 +가 없음): 200.

프로덕션 Discourse 인스턴스 대조 검증: 동일한 페이로드 구조, base64에 + 하나 → 422; 0개 → 200; 그리고 422 페이로드는 +%2B로 사전 이스케이프되면 성공합니다(즉, 추가 디코딩을 위한 사전 보상).

기존 회귀 스펙도 POST 전에 Rack::Utils.parse_query(sso.payload)를 통해 페이로드를 왕복 처리합니다 — 사전 디코딩하여 컨트롤러의 추가 디코딩을 상쇄합니다 — 따라서 +를 포함하는 페이로드조차 이를 통과할 수 있습니다.

권장 수정 사항

쿼리 문자열을 다시 구성하기 전에 파라미터를 재이스케이프하여, 같은 클래스에서 SSO 제공자 측이 이미 CGI.escape(payload)로 수행하는 것을 모방합니다 (discourse_connect_base.rb#L168):

sso = DiscourseConnect.parse(
  "sso=#{CGI.escape(params[:sso])}&sig=#{params[:sig]}", server_session:
)

통합 개발자를 위한 우회 방법 (수정될 때까지)

sync_sso 호출에만(브라우저 로그인 리다이렉트는 아님) sso 값에서 +%2B로 사전 이스케이프하십시오. 엔드포인트가 수정되면 이 우회 방법은 과잉 인코딩(over-encode)을 일으키므로 제거해야 한다는 점에 유의하십시오.

2개의 좋아요

리포트 감사합니다 @sweetbeems :+1: 다음 링크에서 수정됩니다.

3개의 좋아요

정말 감사합니다 @zogstrip !!