김솔비 블로그
기술 블로그

공개 키 한 번에 본문 36개가 새고 있었다: 프롬피드 보안 점검기

13분 읽기

TL;DR

  • 사이트 JS에 들어 있는 Supabase 공개 키로 요청 한 번만 보내면 공개 글 본문 전체가 내려왔다(2026-10-18 재현: 36개). 본문 칸을 공개 키에서 잠그고, 사이트 서버만 비밀 키로 읽게 바꿨다.
  • 조회·저장 수를 셀 때 쓰던 방문자 IP를 요청하는 쪽이 마음대로 적을 수 있었다. 매번 다른 IP를 적으면 저장(좋아요) 수를 끝없이 올릴 수 있었다. 위조할 수 없는 헤더를 먼저 쓰도록 바꾸고, 운영에서 막힌 것을 확인한 뒤 확인용 함수를 지웠다.
  • 고치는 것만큼 순서가 중요했다. 마이그레이션을 코드보다 먼저 실행하면 사이트가 본문을 못 읽는다. CSP는 광고 심사 중이라 일부만 강제했다.

들어가며

프롬피드는 SNS에서 화제가 된 AI 프롬프트를 모아 빈칸만 채우고 복사해 쓰는 사이트다. Next.js와 Supabase로 만들었고, 회원가입 없이 누구나 본다. 그래서 "막을 게 있나?" 싶었다. 로그인도 없고, 결제도 없고, 개인정보라고 해 봐야 문의 이메일과 푸시 구독 정도다.

10월 중순, 검색 등록 뒤 방문이 늘기 시작해서 한 번 제대로 점검해 보기로 했다. 결론부터 말하면, 고칠 게 꽤 있었다. 이 글은 무엇이 열려 있었고, 어떻게 확인했고, 어떤 순서로 닫았는지에 대한 기록이다.

한눈에 보기

무엇이어떻게 열려 있었나바꾼 것
프롬프트 본문공개 키로 요청 한 번에 전체 본문본문 칸을 공개 키에서 잠그고 서버만 비밀 키로 읽기 (0026)
조회·복사·저장 수방문자가 IP 헤더를 마음대로 적어 저장 수 무한 증가위조할 수 없는 cf-connecting-ip를 먼저 쓰기 (0027)
문의·제보·푸시 구독같은 사람이 계속 보낼 수 있음, 아무 주소나 푸시 구독보낸 사람 해시별 10분 제한, 푸시 서버 주소만 허용 (0024)
백업문의 이메일·푸시 키가 git 브랜치 기록에 남음개인정보 빼고, 12주 뒤 지워지는 Actions 아티팩트로
보안 헤더·CSPCSP가 보고만 하고 아무것도 막지 않음광고와 상관없는 4개 규칙만 강제
cron 비밀값!== 문자열 비교해시 후 timingSafeEqual
프레임워크Next.js 16.3.6의 high 취약점16.4.2로 올림, npm audit --omit=dev 0건

1. 공개 키 한 번에 본문이 통째로

문제

Supabase는 브라우저가 DB를 직접 부를 수 있게 공개 키(anon key)를 준다. 이 키는 사이트 JS에 그대로 들어 있어서 누구나 볼 수 있다. 그래서 공개 키로 무엇을 읽을 수 있는지는 RLS(행 수준 보안)와 권한이 전부 정한다.

프롬피드는 화면에 쓰는 통계(조회·복사·저장 수, 이번 주 순위)를 prompts_public 뷰로 계산해 두고, 이 뷰를 공개 키로 읽을 수 있게 열어 두었다. 문제는 뷰에 본문 칸(content)도 같이 있었다는 점이다.

GET /rest/v1/prompts_public?select=content
apikey: <사이트 JS에 들어 있는 공개 키>

10월 18일에 실제로 보내 보니 공개된 글 36개의 본문이 한 번에 내려왔다. 프롬피드의 핵심 콘텐츠가 프롬프트 본문이니, 사이트를 통째로 긁어 가는 데 요청 한 번이면 충분했던 셈이다.

고친 방법: 본문은 서버만 읽는다

방향은 단순하다. 공개 키로는 본문을 못 읽게 잠그고, 사이트 서버만 비밀 키로 읽는다.

DB 쪽(0026)은 표와 뷰를 각각 잠갔다.

sql
-- prompts 표: 방문자(anon)는 못 읽고 관리자만 drop policy if exists "public read prompts" on prompts; create policy "admin read prompts" on prompts for select using (is_admin()); revoke select on prompts from anon; -- prompts_public 뷰: 본문 칸(content·variables·tips)을 빼고 칸 단위로만 연다 revoke select on prompts_public from anon, authenticated; grant select ( id, slug, title, summary, models, sns_source, result_preview, result_image_url, view_count, copy_count, published_at, is_new, category_slug, category_name, trend_delta, tags, like_count, published_ts, week_copies, week_likes, prev_week_copies, prev_week_likes ) on prompts_public to anon, authenticated;

grant select (칸 목록)처럼 칸 단위 권한을 주면 뷰 자체는 그대로 쓰면서 본문 칸만 뺄 수 있다. 브라우저가 상세 화면에서 조회·복사·저장 수를 새로 읽는 기능은 그대로 살아 있다.

코드 쪽은 서버 전용 읽기 클라이언트를 따로 만들었다.

ts
// 사이트 서버가 공개 글을 읽는 클라이언트. 서버 전용 비밀 키로 읽는다. // 비밀 키는 RLS 를 거치지 않으므로, 공개 조건(공개 상태·공개 시각)은 부르는 쪽에서 직접 건다. export const supabaseReader = url && secretKey ? createClient(url, secretKey, { auth: { persistSession: false } }) : supabase;

여기서 놓치기 쉬운 함정이 하나 있다. 비밀 키는 RLS를 건너뛴다. RLS 정책에 있던 "공개 상태이고 공개 시각이 지난 글만"이라는 조건이 사라진다는 뜻이다. 그대로 두면 예약 글이 공개 시각 전에 사이트에 나온다. 그래서 본문을 읽는 쿼리마다 조건을 직접 걸었다.

ts
supabase.from("prompts").select(cols) .eq("status", "published") .lte("published_at", new Date().toISOString());

순서가 틀리면 사이트가 멈춘다

이 작업은 코드 배포와 마이그레이션 사이에 순서가 있다.

순서결과
비밀 키 등록 → 코드 배포 → 0026 실행배포 직후에는 서버가 비밀 키로 읽고, 0026 뒤에는 공개 키가 막힌다. 그 사이 언제든 사이트는 정상
0026 먼저 실행 → 코드 배포옛 코드는 공개 키로 본문을 읽는데 그 길이 막혀서, 배포가 끝날 때까지 상세 페이지에 본문이 안 나온다

그래서 PR 설명과 마이그레이션 파일 머리말에 순서를 굵게 적어 두었다. 새 코드는 비밀 키가 없으면 공개 키로 읽도록 해서, 로컬 개발이나 마이그레이션 전에도 깨지지 않게 했다. 실행한 뒤에는 공개 키로 같은 요청을 다시 보내 본문이 막혔는지 확인했다.

2. IP를 마음대로 적어 저장 수 부풀리기

문제

프롬피드는 조회·복사·저장 수를 브라우저가 Supabase 함수(track_event)를 직접 불러서 센다. 같은 사람이 저장을 여러 번 눌러도 한 번만 세려고 IP 해시로 중복을 걸렀다. 이 IP는 x-forwarded-for 헤더의 첫 주소에서 가져왔다.

x-forwarded-for는 요청하는 쪽이 마음대로 적어 보낼 수 있는 헤더다. 프록시는 그 뒤에 자기가 본 주소를 덧붙일 뿐, 맨 앞 값은 손대지 않는다. 그래서 요청마다 다른 IP를 적으면 매번 "새 방문자"가 되어 저장 수가 끝없이 올라간다. 푸시 구독의 IP별 제한도 같은 방법으로 피할 수 있었다.

고친 방법: 위조할 수 없는 헤더를 먼저

Supabase 앞단에는 Cloudflare가 있다. Cloudflare는 cf-connecting-ip를 자기가 본 실제 접속 주소로 덮어쓴다. 클라이언트가 이 헤더를 직접 넣으면 거부된다. 그래서 IP를 한 곳에서 계산하는 함수를 만들고, 이 헤더를 가장 먼저 보게 했다(0027).

sql
create or replace function request_ip() returns text language sql stable set search_path = public as $$ select nullif(trim(coalesce( nullif(h ->> 'cf-connecting-ip', ''), nullif(h ->> 'x-real-ip', ''), split_part(coalesce(h ->> 'x-forwarded-for', ''), ',', 1) )), '') from (select coalesce(nullif(current_setting('request.headers', true), ''), '{}')::json as h) s; $$; revoke all on function request_ip() from public, anon, authenticated;

track_event와 push_subscribe는 함수 안쪽만 request_ip()를 쓰도록 바꿨다. 함수 몸통만 바뀌니 코드 배포와 순서가 상관없다.

정말 막혔는지 운영에서 확인하기

로컬 Postgres에서는 Cloudflare가 없으니 "운영에서 정말 cf-connecting-ip가 들어오는지"는 확인할 수 없다. 그래서 0027에 확인용 함수 request_ip_headers()를 같이 넣었다. 부른 사람 자신의 IP 관련 헤더와 실제로 쓰인 값만 돌려주는 함수다.

운영에 적용한 뒤 x-forwarded-for와 x-real-ip를 일부러 가짜 값으로 바꿔 보냈다. 결과는 이랬다.

확인결과
실제로 쓰인 IP가 가짜 값인가아니다
실제로 쓰인 IP가 cf-connecting-ip와 같은가같다

확인이 끝난 확인용 함수는 그대로 두면 누구나 부를 수 있는 함수가 하나 더 있는 셈이다. 그래서 다음 마이그레이션(0028)에서 지웠다. 지운 뒤에는 같은 요청이 404를 돌려주는 것까지 봤다. 확인하려고 연 문은 확인이 끝나면 바로 닫는다.

3. 도배 방지: IP는 해시로만, 하루만

문의·제보 폼과 푸시 구독은 아무나 계속 보낼 수 있었다. 전체 상한(10분 30건 등)은 있었지만, 한 사람이 그 상한을 다 써 버리면 다른 사람이 못 보낸다. 그래서 보낸 사람별 제한을 넣었다(0024).

"보낸 사람"을 알려면 IP가 필요하다. 하지만 IP 원본을 DB에 쌓고 싶지 않았다. 그래서 서버 비밀값을 섞은 해시만 넘긴다.

ts
/** IP + 비밀값 → sha256 hex. IP 를 모르면 null */ export function hashRequester(ip: string | null, salt: string): string | null { if (!ip) return null; return createHash("sha256").update(`${salt}:${ip}:rate`).digest("hex"); }
  • 비밀값을 섞었으니 해시만 보고 IP를 거꾸로 맞혀 보기 어렵다. IPv4는 경우의 수가 40억 남짓이라 소금 없는 해시는 금방 풀린다.
  • 기록은 하루가 지나면 지운다. 개인정보처리방침에도 "IP 해시를 하루 보관"이라고 적었다.
  • 새 함수(submit_inquiry_v2 등)는 서버 비밀값을 확인해서 서버에서만 부를 수 있다. 옛 함수의 공개 실행 권한은 뺐다. 브라우저가 RPC를 직접 불러 숨김 필드(honeypot)를 건너뛰는 길이 막힌다.

푸시 구독은 주소도 검사한다. 웹 푸시 주소는 브라우저 회사의 푸시 서버여야 한다. 아무 주소나 받으면 주간 알림을 보낼 때 서버가 그 주소로 요청을 보내게 된다.

ts
const HOST = /^(fcm\.googleapis\.com|updates\.push\.services\.mozilla\.com|([a-z0-9-]+\.)*push\.apple\.com|([a-z0-9-]+\.)*notify\.windows\.com)$/;

user@host, 다른 포트, fcm.googleapis.com.evil.com 같은 주소는 거절한다. 같은 규칙을 DB 함수와 코드 양쪽에 두고, 이미 들어와 있던 가짜 주소는 마이그레이션에서 지웠다.

4. 백업이 git 기록에 남아 있었다

주간 백업은 GitHub Actions가 DB를 JSON으로 내려받아 backups 브랜치에 커밋하고 있었다. 여기에 문의 이메일과 푸시 구독 키가 들어 있었다. 더 큰 문제는 "12주만 보관"이라고 적어 두었지만, 커밋으로 쌓이니 git 기록에는 영원히 남는다는 점이었다.

  • 백업에서 inquiries.email과 push_subscriptions를 뺐다. DB 함수에서 한 번, 워크플로에서 받은 JSON에서 한 번 더 지운다(마이그레이션 전에 돌아도 안전하게).
  • 저장 위치를 브랜치 커밋에서 Actions 아티팩트(retention-days: 84)로 바꿨다. git 기록이 생기지 않고, 12주 뒤 GitHub가 자동으로 지운다. "12주만 보관"이 비로소 사실이 됐다. 워크플로 권한도 contents: read로 줄였다.

매주 orphan 커밋으로 브랜치를 강제로 덮어쓰는 방법도 생각했다. 하지만 그래도 지워진 커밋을 GitHub가 한동안 SHA로 보관할 수 있고, 강제 푸시를 매주 자동으로 하는 건 그 자체로 위험해 보였다. 아티팩트가 더 단순했다.

5. CSP는 일부만 강제했다

CSP(Content Security Policy)는 처음에 Report-Only로 넣었다. 막지는 않고 위반만 브라우저 콘솔에 보고한다. 애드센스, GA, 카카오 SDK처럼 바깥 스크립트가 많아서, 한 번에 강제하면 어디가 깨질지 몰랐다.

그런데 Report-Only는 아무것도 막지 않는다. 그래서 규칙을 둘로 나눴다.

구분규칙이유
강제object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'self'광고·분석 스크립트와 상관없다. 바로 막아도 깨질 게 없다
보고만script-src, connect-src, img-src 등애드센스가 아직 승인 전이라 광고 스크립트가 실제로 무엇을 부르는지 확인할 수 없다

강제로 바꾸기 전에 운영 화면 8개(PC·모바일)를 Playwright로 열어 위반이 없는 것을 확인했다. 광고가 붙은 뒤 며칠 위반 보고를 보고 나머지를 강제할 생각이다.

6. 그 밖에 고친 것

  • Next.js 16.3.6 → 16.4.2: ISR 캐시 오염, 이미지 최적화 SSRF 같은 high 취약점이 있었다. 운영 의존성 npm audit 0건. 개발 의존성(vitest)도 따로 올렸다.
  • cron 비밀값 비교: /api/cron/*은 Authorization: Bearer <비밀값>으로 Vercel Cron인지 확인한다. !==로 비교하면 몇 글자째에서 틀렸는지가 응답 시간으로 샐 수 있다. 길이까지 숨기려고 둘 다 해시한 뒤 같은 길이로 비교한다.
ts
const digest = (s: string) => createHash("sha256").update(s).digest(); export function isCronRequest(request: Request, secret = process.env.CRON_SECRET): boolean { if (!secret) return false; return timingSafeEqual(digest(request.headers.get("authorization") ?? ""), digest(`Bearer ${secret}`)); }
  • 보안 헤더: X-Powered-By 빼기, 쓰지 않는 기능(결제·USB·블루투스 등)을 Permissions-Policy로 막기, Cross-Origin-Opener-Policy: same-origin-allow-popups.
  • 입력 길이 상한: 문의 이메일 254자, 제보 주소 500자.
  • 환경변수: 알림 발송에 쓰는 비밀 키를 Vercel에서 "Sensitive"로 다시 저장했다. 대시보드에서도 값이 보이지 않는다.
  • 본문 복사: 상세 화면 본문은 복사 버튼으로만 복사하게 하고, AI 학습용 수집기를 robots.txt에서 막았다. 다만 이건 보안이 아니다. 검색 노출을 위해 본문은 HTML에 있어야 하니 소스 보기로는 여전히 보이고, robots.txt는 지키는 봇에만 효과가 있다.

CSRF 토큰은 넣지 않았다

관리자 기능은 모두 Next.js 서버 액션이다. 서버 액션은 요청의 Origin과 Host를 비교해서 다른 사이트에서 보낸 요청을 거절한다. 거기에 모든 관리자 액션은 첫 줄에서 관리자 세션을 확인한다(requireAdmin()). 같은 일을 두 번 하는 토큰을 따로 넣기보다, 이 두 겹을 확인하는 쪽을 택했다.

아직 남은 숙제

  • 이메일 회원가입 끄기: 사이트에 회원가입이 없는데, Supabase 인증 설정에서 새 가입이 아직 열려 있다. 공개 키로 계정을 만들 수 있다는 뜻이다. 만들어도 관리자 권한은 없지만, 쓰지 않는 문은 닫아 두는 게 맞다.
  • CSP 나머지 강제: 광고 승인 뒤 위반 보고를 보고 정한다.
  • 옛 백업 브랜치: 새 백업은 더 이상 쓰지 않지만, 예전 커밋 기록은 브랜치를 지워야 사라진다.

배운 것

  1. "공개 키"는 "공개해도 되는 키"가 아니라 "공개될 수밖에 없는 키"다. 무엇을 읽을 수 있는지는 키가 아니라 권한이 정한다. 뷰를 하나 열 때도 칸 단위로 무엇이 나가는지 봐야 한다.
  2. 헤더는 누가 쓰는지부터 따진다. 같은 "방문자 IP"라도 요청하는 쪽이 쓰는 헤더와 프록시가 덮어쓰는 헤더는 믿을 수 있는 정도가 다르다.
  3. 고치는 것만큼 순서와 확인이 중요하다. 마이그레이션과 배포의 순서를 적어 두고, 운영에서 실제로 막혔는지 확인하고, 확인용으로 연 문은 바로 닫았다.
  4. "보관 기간"은 저장 방식이 지켜 줘야 사실이 된다. git 커밋으로 쌓이는 백업은 "12주만"이라고 적어도 12주 뒤에 사라지지 않는다.

비용이 들지 않는 작은 사이트라도, 열려 있는 문은 생각보다 많았다. 다음 점검 때는 이 표부터 다시 볼 생각이다.