기술

링크를 흘리는 건 사람이 아니라 기계였습니다, 열쇠를 URL 밖으로 뺐습니다

2026.09.129분 읽기

고객사에 새 기능 안내문을 보낼 때, 주소 하나만 전달하는 방식을 쓰고 있었습니다. 무작위 문자 43자가 박힌 주소라 손으로 맞히는 것은 사실상 불가능합니다. 계정 발급도 로그인도 없이 담당자가 링크만 누르면 바로 읽는 구조였습니다.

그러다 이미 내보낸 안내문을 급히 내린 일이 있었습니다. 내용이 경쟁사 쪽에 보일 수 있다는 우려가 나왔고, 4건을 3시간 반 만에 내렸습니다. 그중 1건은 우려가 나온 지 42초 만에 내려갔습니다. 그 뒤로 살아 있는 고객 링크는 0건이 됐습니다.

링크를 다시 켜려면 이 주소가 안전한가에 먼저 답해야 했습니다. 감으로 답하지 않고 공개된 사고 사례부터 찾아봤습니다.


추측으로는 못 뚫는다는 말은 맞았습니다

무작위 43자면 경우의 수가 사람이나 스크립트가 훑을 수 있는 범위를 한참 벗어납니다. 무차별 대입으로 남의 안내문을 여는 일은 현실적으로 일어나지 않습니다. 여기까지는 사실이었습니다.

문제는 주소를 여는 쪽이 그것을 맞힐 필요가 없다는 데 있었습니다. 실제 사고는 대개 무차별 대입이 아니라 유출로 일어납니다. 주소 하나가 만들어지는 순간부터 그 문자열은 여러 곳으로 복제되기 시작합니다.


링크를 흘리는 것은 사람이 아니라 기계입니다

찾아본 사례에서 반복해 나온 경로는 네 갈래였습니다. 넷 다 누가 링크를 실수로 붙여넣는 것과는 무관합니다.

URL 스캔·위협 인텔리전스 서비스 — 누군가 이 주소가 안전한지 확인하려고 검사기에 넣으면 그 서비스에 기록으로 남습니다. 대형 파일공유 서비스의 추측 불가 링크가 이 경로로 검색 가능해진 사례가 공개돼 있습니다.

Referer 헤더 — 그 화면에서 외부 링크를 한 번만 눌러도 전체 주소가 상대 사이트의 서버 로그로 넘어갑니다.

메일·메신저의 링크 안전성 검사 — 사람이 누르기 전에 기계가 먼저 그 주소를 엽니다. 수신함에 도착한 것만으로 열람이 일어납니다.

브라우저 기록·프록시 로그·사내 보안 장비 — 전부 전체 주소를 통째로 보관합니다.

한 연구는 600만 개 URL을 훑어 12,331건이 이런 경로로 노출된 것을 찾았습니다. 거기에는 아직 살아 있는 비밀번호 재설정 링크 26건이 들어 있었습니다. 이미 다른 곳에서 같은 방식으로 새고 있었고, 우리 링크가 그 조건에서 예외일 이유는 없었습니다.

네 경로를 나란히 놓고 보니 공통점이 하나 있었습니다. 주소가 곧 열쇠인 구조에서는 주소를 한 번 본 모든 시스템이 열쇠를 한 벌씩 갖게 됩니다.

142-1.jpg


토큰 아래층이 비어 있었습니다

우리 쪽 상태를 확인해보니 noindex도, robots.txt도, Referrer-Policy도 없었습니다. 토큰이 세다는 사실 하나로 그 아래 점검이 뒤로 밀려 있던 상태였습니다. 흔한 누락이고, 강한 방어 하나가 나머지를 안 보게 만드는 구조 쪽이 원인에 가깝습니다.

그래서 이번 작업에는 robots.txt와 X-Robots-Tag, Referrer-Policy를 함께 넣었습니다. 문서를 새 창으로 열 때는 noopener noreferrer를 붙여 목록 화면의 주소가 Referer로 넘어가지 않게 했습니다. 링크를 지키려는 기능이 정작 자기 주소를 흘리면 앞뒤가 맞지 않습니다.


세 가지 안을 놓고 골랐습니다

무엇이 달라지나

판단

토큰을 더 길게

추측 저항만 커진다

새는 경로는 그대로라 접었습니다

계정 로그인 요구

확실하게 막힌다

담당자마다 계정을 발급·관리해야 해서 접었습니다

고객사 단위 열람 비밀번호

열쇠가 URL 밖으로 나간다

채택했습니다

채택 이유는 열쇠의 위치입니다. 비밀번호를 쓰면 여는 데 필요한 값이 주소에서 사람의 머리로 옮겨갑니다. 주소가 스캐너에 등록되든 Referer로 넘어가든, 그것만으로는 문서가 열리지 않습니다. 비밀번호를 바꾸면 그동안 나간 링크가 한꺼번에 무효가 됩니다.

저장 방식은 기존 API 키 패턴을 그대로 따랐습니다. 맞는지 확인만 하면 되는 값은 해시로 비교하고, 나중에 다시 보여줘야 하는 값은 대칭키로 암호화해 둡니다. 새 저장 규칙을 만들지 않은 덕분에 나중에 다시 검토할 표면도 늘지 않았습니다.

142-2.jpg


비밀번호를 문서 상태보다 먼저 검사합니다

순서를 뒤집으면 정보가 샙니다. 문서 상태를 먼저 보면 주소만 주운 사람이 없는 문서와 있지만 아직 발행 전인 문서를 응답 차이로 구분할 수 있습니다. 어떤 문서가 존재한다는 사실 자체가 고객사 쪽에는 정보입니다.

그래서 비밀번호 검사를 앞에 두고, 실패했을 때는 문서가 있든 없든 발행됐든 아니든 같은 응답을 돌려줍니다. 이 순서는 리팩터링에서 쉽게 뒤집힙니다. 코드만 보면 상태 검사가 앞에 오는 편이 더 자연스러워 보이기 때문입니다. 순서 자체를 테스트로 고정해두고 넘어갔습니다.

142-3.jpg


공개 경로에 공용 HTTP 클라이언트를 쓰지 않았습니다

관리 화면에서 쓰던 공용 HTTP 클라이언트에는 인터셉터가 붙어 있습니다. 401을 받으면 토큰 갱신을 시도하고, 그것도 실패하면 로그인 화면으로 보냅니다. 로그인한 직원을 대상으로 만든 동작이라 그 자리에서는 잘 맞습니다.

이 인스턴스를 공개 열람 화면에 그대로 쓰면 이야기가 달라집니다. 비밀번호를 한 번 틀린 고객사 담당자가 우리 내부 관리 도구의 로그인 화면 앞에 서게 됩니다. 인증 실패가 곧 로그인하라는 뜻이 아닌 자리가 있고, 이 화면이 정확히 그 자리였습니다. 공개 경로만 원시 fetch로 갈라냈습니다.

인증이 걸려 있는 줄 알았던 경로가 실제로는 열려 있던 사례도 같은 계열입니다. 새로 만든 경로는 기존 규칙을 자동으로 물려받지 않습니다.


막지 않기로 한 것을 먼저 합의했습니다

고객사 담당자가 내용을 의도적으로 밖으로 흘리는 것은 막지 않기로 했습니다. 계정 로그인을 붙여도 화면을 캡처하면 끝이고, 그 지점부터는 기술이 아니라 계약과 신뢰의 영역입니다. 막을 대상을 의도치 않게 새는 것으로 좁혔습니다.

비목표를 안 적어두면 방어가 계속 커집니다. 시도 제한, 워터마크, 접속 기록처럼 하나씩 붙일 이유는 언제든 만들 수 있고, 그러다 보면 안내문 하나 보는 데 절차가 세 개 생깁니다. 어디까지 막을지보다 어디부터는 안 막을지를 먼저 적어두는 편이 결정이 빨랐습니다.

가드를 어느 강도에서 멈출지는 위협 모델을 먼저 그려야 정해집니다. 같은 방식으로 강도를 정한 기록이 하나 더 있습니다.


남은 구멍도 같이 적었습니다

비밀번호가 고객사 단위라 담당자가 바뀌어도 그대로 살아 있습니다. 사람 단위로 끊어야 할 상황이 오면 결국 계정이 답입니다.

비밀번호도 결국 사람이 전달합니다. 링크와 비밀번호를 같은 메신저 창에 나란히 보내면 분리한 의미가 사라집니다.

시도 제한을 두지 않았습니다. 토큰을 먼저 알아야 시도라도 할 수 있다는 전제인데, 이 방어 자체가 토큰이 새는 상황을 가정하고 있어 전제가 조금 어긋납니다.


공개 링크를 점검할 때 보는 것

이 주소가 밖으로 나갔을 때 무엇이 보이는지 한 줄로 적어봅니다.

여는 데 필요한 값이 주소 안에 들어 있는지 확인합니다. 들어 있다면 헤더나 본문으로 옮길 수 있는지 봅니다.

robots.txt·X-Robots-Tag·Referrer-Policy가 실제 응답에 실려 나오는지 직접 호출해서 확인합니다.

인증 실패와 문서 없음과 미발행의 응답이 서로 구분되는지 봅니다. 구분되면 그 차이가 곧 정보입니다.

막지 않을 것을 문서로 합의해 둡니다.


마무리

링크는 다시 켰습니다. 주소를 주워도 비밀번호가 없으면 열리지 않고, 비밀번호를 바꾸면 그동안 나간 링크가 한꺼번에 막힙니다. 검색엔진과 크롤러가 타고 들어오던 경로도 같이 닫혔습니다.

추측 불가능이라는 말은 무차별 대입만 막는 방어입니다. 주소에 비밀을 담고 있다면, 그 주소가 만들어진 뒤 어디를 지나는지 세어보는 것부터 해볼 만합니다. 저희는 네 곳까지 세고 나서 열쇠를 옮기기로 정했습니다.

#추측불가토큰#URL유출#Referrer정책#위협모델링#접근제어