안녕하세요! 2채널 호환 게시판 시스템 "츠우바이채널(つゔぁいちゃんねる, 2ch.biz)" 개발 기록의 제55회입니다.
제2부까지에서는 WebRTC를 활용한 완전 암호화 P2P 채팅이라는 차세대 실시간 기능을 완성했습니다.
그리고 이번부터는 드디어 **"제3부: 모던 웹의 표현력과 전용 브라우저 호환성 초월"**을 시작합니다!
오래된 2채널의 매력이라고 한다면 꾸밈없는 단순한 텍스트 문화였습니다.
하지만 Discord, X(구 트위터), Slack 등이 당연해진 레이와 시대에 와서도 스레드나 채팅창에 파란색 URL 문자열이 그저 덩그러니 붙어 있는 화면은 어딘가 쓸쓸하고, 클릭하기 전까지 어떤 기사인지 알 수 없습니다.
"뉴스 기사나 YouTube의 URL을 붙여 놓으면,
썸네일 이미지와 제목, 기사의 개요가 한 번에 카드 형태로 미리보기로 표시되었으면 좋겠다!"
하지만 이를 웹 브라우저 상에서 구현하려고 하면, **'브라우저의 CORS 제한(Cross-Origin Resource Sharing)'**과, 이를 회피하기 위해 설치하는 서버 사이드 프록시가 안고 있는 **'SSRF(Server-Side Request Forgery) 공격의 공포'**라는 거대한 벽에 부딪힙니다.
이번에는 안전한 OGP 프록시 API 설계와 모던 블로그 카드 자동 전개 기능 구현 과정을 전달해 드립니다!
클라이언트(JavaScript)에서 외부 뉴스 사이트 https://news.example.com의 HTML을 fetch()하려고 해도, 브라우저의 동일 생성자 정책(Same-Origin Policy) 및 CORS 제한에 의해 차단됩니다.
따라서 반드시 **'자체 서버를 경유하여 외부 HTML을 가져오는 프록시 API'**를 준비해야 합니다.
하지만 안일하게 '지정된 URL의 HTML을 가져와 반환하는 API'를 만들 경우, 악의적인 사용자에게 의해 서버 자체나 클라우드의 내부 메타데이터가 공격당할 수 있습니다.
공격자의 요청:
GET /api/ogp?url=http://127.0.0.1:3306/ (로컬 MySQL에 대한 포트 스캔)
GET /api/ogp?url=http://169.254.169.254/ (클라우드 인스턴스의 인증 정보 탈취)
...
이 SSRF 공격을 허용한다면, 서버의 최고 권한이나 데이터베이스가 순식간에 노출될 수 있습니다.
이를 방지하면서도 경쾌하게 작동시키기 위해 다음과 같은 다층 방어 아키텍처를 구축했습니다.
- URL 스킴 제한:
http:// 또는 https://만 허용 ( file://, gopher://, dict:// 등은 완전히 차단).
- DNS 이름 해석 및 프라이빗 IP 판별: 요청 전에 호스트명을 이름 해석하고, 루프백(
127.0.0.1), 링크 로컬(169.254.*), 프라이빗 주소(10.*, 172.16-31.*, 192.168.*)를 철저히 배제.
- 데이터 양 및 타임아웃 제한: 응답 크기는 최대 1MB로 잘라내고, 타임아웃은 3초로 설정.
- 24시간 캐시: 동일 URL에 대한 과도한 외부 접근을 막고 표시 속도를 극대화.
Node.js와 PHP 양쪽 모두에서 엄격한 IP 검증과 HTML 메타 태그 추출 기능을 구현했습니다.
// server.js (OGP 프록시)
const dns = require('dns').promises;
const ipRangeCheck = require('ip-range-check');
...
🛡️ 고급 보안 해설: DNS Rebinding 및 리다이렉트 추적 대응책
위의 코드 예시는 기본적인 이름 해석과 IP 범위 판별을 보여주지만, 실제 환경에서 더욱 완벽한 SSRF 방어를 목표로 한다면 다음 3가지에도 배려가 필요합니다.
HTTP 리다이렉트(30x) 추적 제어: fetch에 redirect: 'manual'을 지정하고, 리다이렉트된 URL의 IP 주소 역시 프라이빗 대역이 아닌지 재검증해야 합니다.
DNS Rebinding (TOCTOU) 대응책: 판별 시점과 fetch 시점의 이름 해석에서 다른 IP가 반환될 위험을 막기 위해, 검증된 IP 주소에 직접 HTTP 요청을 전송하거나(또는 커스텀 Agent로 DNS Pinning 수행).
IPv6 지원: dns.resolve4
뿐만 아니라 dns.resolve6도 함께 사용하여, IPv6 루프백(::1)이나 유니크 로컬(unique local, fc00::/7)을 확실하게 차단합니다.
게시물 본문 내의 URL을 정규 표현식으로 감지하여, 게시물 하단에 세련된 블로그 카드를 비동기적으로 추가합니다.
// app.js
async function renderOgpCardsForPost(postElement, content) {
const urlRegex = /(https?:\[\/\/[^\s<]+[^<.,:;"][']\s])/g;
...
테스트 스크립트를 실행하여, 공격 URL 차단과 정상 URL의 메타데이터 추출을 철저히 검증했습니다.
$ node test_ogp_feature.js
[TEST 1] SSRF 공격 감지: http://127.0.0.1:3306 ... 403 Forbidden (방어 성공!)
[TEST 2] SSRF 공격 감지: http://169.254.169.254/latest/meta-data ... 403 Forbidden (방어 성공!)
...
URL만 붙여도 스레드의 주제가 한눈에 파악되는 리치한 블로그 카드입니다.
'화려한 외관'의 이면에는, 서버를 발판으로 삼는 사이버 공격(SSRF)을 완벽하게 방어하는 견고한 다층 방벽이 존재합니다.
이렇게 화려하게 데뷔한 블로그 카드 기능이었지만, 게시판이 활기를 띠면서 오랜 기간 텍스트만으로 2ch를 즐겨온 기존 사용자들로부터 다음과 같은 의견이 접수되었습니다.
"이미지가 포함된 카드는 너무 커서 화면을 점령해 방해가 돼!"
"옛날 방식의 '> 인용문' 같은 단순한 텍스트 표시로 할 수 없을까?"
"하지만, 스마트폰으로 볼 때는 이미지가 있는 편이 보기 좋고... 사람마다 취향 차이가 너무 심하잖아!"
게시판의 사용자층은 실로 다양합니다. 모두를 만족시키는 UI란 무엇일까요?
실제 서비스: https://2ch.biz/