URL의 공백이 %2520으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인 순으로 간다.

① 깨진 값 해독: decodeURIComponent 를 한 번 돌려도 %20 이 남으면 이중 인코딩이고, 한글은 풀리니 공백만 다시 인코딩됐다. ② 경로 좁히기: 액세스 로그의 %2520 요청이 iOS 에 몰리고 Referer 가 비어 있으면 복사-붙여넣기 경로다. ③ 우리 코드 배제: 링크 생성이 인코딩을 한 번만 하면 원인은 밖이고, iOS 버전으로 좁혀 WebKit Bug 261936 을 찾는다. ④ 고칠 수 없으니 막는다: 화이트리스트 라우트에서만 한 번 더 디코딩, 디코딩 뒤 .. // \ 차단, segment 별 재인코딩 후 301. 무조건 이중 디코딩은 ..%252f 를 ../ 로 만들어 path traversal 이 된다. ⑤ 방어 확인: curl -I 로 %2520 URL 은 301 로 정상 경로, ..%252f URL 은 차단되는지 본다.

1. 깨진 값을 해독한다

decodeURIComponent('%2520')을 한 번 돌리면 %20이 남는다 — %25(= %) 뒤에 원래의 20, 두 번 인코딩된 것이다. 같은 URL의 한글(%EC%88%98)은 한 번 디코딩으로 풀리니 공백만 다시 인코딩됐다. URL 인코딩은 멱등이 아니다.

2. 어느 경로에서 깨지나

서버 액세스 로그에서 %2520이 든 요청만 모아 User-Agent·Referer를 센다. iOS에 몰려 있고 Referer가 비어 있다 — 링크 클릭(JS redirect)이 아니라 주소를 복사해 붙여넣은 경로다. 클릭 경로의 같은 URL은 정상이다.

3. 우리 코드인가, 밖인가

링크를 만드는 코드에서 encodeURIComponent 호출을 찾아 출력이 %20 한 번인지 본다. 한 번이면 이중 인코딩은 우리 밖에서 일어난다. iOS 버전으로 좁혀 검색하면 WebKit Bug 261936이 나온다.

  • iOS 16↓: NSURL URLWithString:이 invalid char에 nil
  • iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)
  • iOS 17.2: 수정

공백 → 인코딩 1회 → %20 → iOS 17.0·17.1 pasteboard 가 NSURL 을 만들며 %20 을 invalid 로 판단해 % 를 다시 인코딩 → %2520. 한글 가 → 인코딩 1회 → %EA%B0%80 → 유효한 UTF-8 이라 통과 → %EA%B0%80 그대로. %2520 은 %25(= %) 뒤에 원래 20 이 붙은 것으로, 공백만 한 번 더 인코딩됐다. 클릭(JS redirect)은 pasteboard 를 거치지 않아 정상이다.

4. 고칠 수 없으니 막는다

원인이 OS 버그면 추적보다 방어가 싸다.

  • 화이트리스트 라우트 패턴에서만 한 번 더 디코딩
  • 디코딩 후 위험 패턴(.., //, \) 차단
  • segment별 encodeURIComponent 후 301 redirect

무조건 이중 디코딩 금지 — ..%252f..%252f → ../../ path traversal 우회 벡터.

5. 방어를 확인한다

%2520이 든 URL과 ..%252f..%252f가 든 URL을 curl -I로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.

교훈

  • URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)
  • “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다

참고

`#539`