~/web-app-dev.md
WEB_APP_DEV

Hono 보안 릴리스가 알려주는 것: 미들웨어는 권한 경계가 아니라 입력 계약이다

10분 읽기·2026.06.11·출처 6·00
오늘의 질문

Hono 같은 가벼운 웹 프레임워크에서 cors(), bodyLimit(), serveStatic()을 붙였다고 해서 실제 보안 경계가 완성됐다고 말할 수 있을까?

web-app-dev.md
SURVIVE.exe

Hono 보안 릴리스가 알려주는 것: 미들웨어는 권한 경계가 아니라 입력 계약이다

1. 왜 지금 봐야 하나

2026년 6월 9일 Hono는 v4.12.25를 내면서 여러 보안 수정을 한 번에 공개했다. 목록만 보면 CORS, body limit, static file serving, AWS Lambda adapter, Lambda@Edge header 처리처럼 서로 다른 버그처럼 보인다. 하지만 웹앱 개발자 관점에서 이 릴리스가 주는 메시지는 하나다.

미들웨어는 “붙이면 끝나는 방어막”이 아니라 런타임, 프록시, 브라우저, 파일시스템과 맺는 입력 계약이다.

Hono는 Cloudflare Workers, Bun, Deno, Node.js, AWS Lambda 등 여러 런타임에서 같은 Web Standards 기반 API로 동작하도록 설계된 프레임워크다. 이 장점은 동시에 위험도 만든다. 같은 TypeScript 코드라도 실제 요청이 들어오는 경로는 다르다.

  • 브라우저는 CORS 응답을 스펙에 따라 해석한다.
  • AWS Lambda adapter는 이미 버퍼링된 이벤트를 다시 Request처럼 조립한다.
  • Windows 파일시스템은 /뿐 아니라 \도 경로 구분자로 해석한다.
  • ALB나 VPC Lattice는 여러 Set-Cookie 헤더를 표현하는 방식이 다를 수 있다.

그래서 이번 릴리스는 “Hono를 당장 써도 되나?”보다 “내 API 미들웨어가 어떤 전제를 믿고 있는가?”를 점검하는 좋은 학습 사례다.

2. 핵심 개념

이번 글의 핵심 개념은 경계의 위치다.

웹앱에서 우리는 자주 이렇게 생각한다.

zsh — 생존확인.sh
app.use('/api/*', cors({ credentials: true }))
app.post('/upload', bodyLimit({ maxSize: 1024 * 1024 }), handler)
app.use('*', serveStatic({ root: './public' }))

코드만 보면 경계가 명확해 보인다.

  • CORS가 cross-origin 접근을 제한한다.
  • bodyLimit이 큰 요청을 막는다.
  • serveStatic이 지정된 폴더 안에서만 파일을 준다.

하지만 실제 보안 경계는 이 한 줄에 있지 않다. 경계는 아래 질문에 의해 결정된다.

  1. 누가 입력을 만들었나? 브라우저, 공격자, 프록시, Lambda 이벤트, 파일 경로 인코더.
  2. 어느 계층이 입력을 먼저 해석했나? 라우터, adapter, 런타임, OS 파일시스템.
  3. 어떤 값이 신뢰된 값으로 승격됐나? Origin, Content-Length, URL path, header 배열.
  4. 실패 시 닫히는가, 열리는가? 차단되는가, 조용히 통과하는가, 일부 쿠키만 사라지는가.

이 관점으로 보면 미들웨어 설정은 “보안 기능을 켰다”가 아니라 “어떤 입력을 어떤 조건에서 신뢰하겠다”는 선언에 가깝다.

3. 최신 이슈와 연결

이번 Hono v4.12.25 보안 릴리스에서 특히 배울 만한 사례는 세 가지다.

1) CORS: credentials: true와 wildcard origin은 같이 두면 위험해진다

GitHub advisory GHSA-88fw-hqm2-52qc에 따르면, 영향받는 버전에서는 credentials: true를 켜고 명시적인 origin을 두지 않으면 기본 wildcard origin과 결합되어 요청의 Origin을 반사하고 Access-Control-Allow-Credentials: true를 보낼 수 있었다. 결과적으로 사용자가 로그인한 상태에서 방문한 제3자 페이지가 cookie-authenticated endpoint를 credentialed cross-origin request로 읽을 수 있는 상황이 생긴다.

Hono CORS 문서도 origin의 기본값이 *이고, credentials 옵션이 따로 존재한다고 설명한다. 즉 개발자가 “개발 중 편의”로 origin을 비워둔 채 credentials만 켜면, 그 조합 자체가 권한 정책이 된다.

중요한 원리는 이것이다.

쿠키 기반 인증 API에서 CORS는 네트워크 설정이 아니라 데이터 읽기 권한 정책이다.

2) Body limit: Content-Length는 런타임에 따라 진실이 아닐 수 있다

GHSA-rv63-4mwf-qqc2는 AWS Lambda 계열 adapter에서 bodyLimit()이 client-declared Content-Length를 믿는 문제를 설명한다. API Gateway, ALB, VPC Lattice, Lambda@Edge에서는 요청 body가 이미 fully buffered 된 이벤트로 전달되고, adapter가 이를 다시 Request로 만든다. 이때 클라이언트가 작은 Content-Length를 선언하면서 실제 payload는 더 크게 보내면, body limit 판단이 선언값에 기대어 통과할 수 있었다.

일반적인 standards-based runtime에서는 transport가 Content-Length와 실제 body 길이를 맞추거나 잘못된 요청을 거부할 수 있다. 하지만 Lambda adapter에서는 “이미 들어온 이벤트를 어떻게 Request로 재구성하느냐”가 경계가 된다.

중요한 원리는 이것이다.

요청 크기 제한은 middleware 한 줄이 아니라 ingress, runtime adapter, handler parsing이 함께 만드는 비용 경계다.

3) serveStatic: URL segment와 OS path segment는 같지 않다

GHSA-wwfh-h76j-fc44는 Windows host에서 encoded backslash %5C\로 decode되고, Windows path resolver가 이를 경로 구분자로 해석하는 문제를 다룬다. 라우터는 / 기준으로 path를 나누기 때문에 /admin%5Csecret.txt를 하나의 segment로 볼 수 있다. 반면 static file resolver는 Windows에서 admin\secret.txt를 nested path처럼 해석할 수 있다.

이 문제는 directory root 밖으로 탈출하는 일반적인 .. traversal과는 다르다. advisory는 root 밖 escape는 불가능하지만, prefix-mounted middleware로 보호하려던 static subtree 안의 파일을 읽을 수 있다고 설명한다.

중요한 원리는 이것이다.

URL 라우팅에서 보호한 prefix와 파일시스템에서 해석된 경로가 항상 같은 경계를 가리키지는 않는다.

4. 개발자 관점 해석

이 릴리스는 Hono만의 문제가 아니다. Next.js Route Handler, Express, Fastify, Remix resource route, SvelteKit endpoint, serverless function 모두 비슷한 함정을 갖는다.

작은 팀에서 자주 생기는 실패 모드는 다음과 같다.

실패 모드 A: “개발 편의 설정”이 프로덕션 권한 정책으로 남는다

프론트엔드 로컬 개발 중에는 CORS를 넓게 열고 싶다. 그래서 origin: '*' 또는 origin 미설정을 사용한다. 문제는 여기에 cookie, session, refresh token 같은 credential이 얹히는 순간이다.

읽기 API가 단순 JSON이라고 해도 사용자별 데이터면 이미 권한 데이터다. CORS는 “어느 프론트엔드 앱이 이 응답을 읽을 수 있는가”를 정하는 정책이므로, 개발/스테이징/프로덕션 origin 목록을 분리해야 한다.

실패 모드 B: adapter가 만든 Request를 원본 HTTP 요청처럼 믿는다

serverless에서는 애플리케이션 코드가 raw socket을 직접 보지 않는다. API Gateway나 ALB가 이벤트를 만들고, framework adapter가 그 이벤트를 Web Request처럼 감싼다. 그러면 Content-Length, repeated header, multi-value header 같은 값은 “네트워크가 보장한 사실”이 아니라 “adapter가 조립한 표현”이 된다.

업로드, webhook, AI 파일 분석, 이미지 처리처럼 body 비용이 큰 API라면 특히 조심해야 한다. 비용 폭탄은 보안 사고이기도 하다.

실패 모드 C: static file을 authorization 뒤에 숨긴다고 착각한다

/admin/* 라우트에 인증 middleware를 걸고, 같은 root 아래 static file을 둔다. URL 기준으로는 안전해 보인다. 하지만 static file serving은 URL을 파일 path로 바꾸는 별도의 해석 단계다. 인코딩, slash 정규화, OS separator, symlink, case sensitivity까지 고려해야 한다.

민감한 파일은 “static root 안에 두고 middleware로 막기”보다 애초에 static root 밖에 두고, 필요한 경우 인증된 handler가 읽어 stream하는 편이 더 단순하다.

5. 내 프로젝트에 적용할 체크포인트

Hono를 쓰고 있다면 아래를 바로 점검하자. Hono가 아니어도 같은 질문은 유효하다.

CORS

  • credentials: true를 쓰는 모든 route를 찾는다.
  • 해당 route의 origin이 명시적인 allowlist인지 확인한다.
  • localhost, preview URL, production domain이 같은 설정에 섞여 있지 않은지 본다.
  • cookie 기반 API라면 Origin: null, 알 수 없는 origin, 허용 origin 각각에 대해 응답 header를 테스트한다.

안전한 방향은 이런 식이다.

zsh — 생존확인.sh
const allowedOrigins = new Set([
  'https://app.example.com',
  'https://admin.example.com',
])

app.use('/api/*', cors({
  origin: (origin) => allowedOrigins.has(origin) ? origin : '',
  credentials: true,
}))

실제 구현에서는 빈 문자열 처리 방식과 preflight 실패 응답을 프레임워크 문서에 맞춰 확인해야 한다. 핵심은 “credential을 허용하는 route는 wildcard를 쓰지 않는다”이다.

Body size

  • bodyLimit() 같은 middleware가 ingress 앞단 제한을 대체한다고 생각하지 않는다.
  • Cloudflare, Vercel, AWS API Gateway, ALB, nginx, CDN 등 앞단의 body size limit도 확인한다.
  • Lambda라면 실제 payload 크기와 선언된 header가 어긋나는 테스트 케이스를 만든다.
  • handler 내부에서 parseBody(), json(), multipart parsing이 언제 메모리를 쓰는지 확인한다.

Static files

  • 보호가 필요한 파일을 static root 안에 두지 않는다.
  • route prefix authorization과 static resolver의 path 정규화가 같은 규칙인지 테스트한다.
  • Windows, Linux 배포 환경이 섞여 있다면 %5C, %2F, repeated slash, encoded dot segment 테스트를 추가한다.
  • “root 밖으로 못 나가니 안전하다”가 아니라 “보호하려던 subtree를 우회할 수 없는가”를 본다.

Cookie/header

  • 한 응답에서 session cookie, csrf cookie, preference cookie처럼 여러 Set-Cookie를 보내는 route를 찾는다.
  • ALB single-header mode, VPC Lattice, API Gateway v1/v2 등 배포 경로별로 실제 response header가 어떻게 보이는지 확인한다.
  • 로그인 성공, refresh, logout, OAuth callback은 브라우저 DevTools와 curl 양쪽에서 검증한다.

6. 오늘 10분 액션

오늘은 코드 전체를 고치려 하지 말고, 딱 하나의 “경계 테스트 파일”을 만든다.

  1. cors({ credentials: true })가 들어간 파일을 검색한다.
  2. 허용해야 하는 origin 2개와 거부해야 하는 origin 2개를 적는다.
  3. 아래 네 가지 요청을 curl 또는 테스트 코드로 만든다.
    • 허용 origin + credential request
    • 미허용 origin + credential request
    • Origin: null
    • preflight OPTIONS with custom header
  4. 응답에서 아래 header를 기록한다.
    • Access-Control-Allow-Origin
    • Access-Control-Allow-Credentials
    • Vary
  5. 마지막으로 package.json에서 hono 버전이 4.12.25 이상인지 확인한다.

예시 체크 명령:

zsh — 생존확인.sh
npm ls hono
curl -i \
  -H 'Origin: https://evil.example' \
  -H 'Cookie: sid=test' \
  https://api.example.com/api/me

10분 안에 끝내려면 업로드와 static file까지 다 보지 않아도 된다. 오늘은 CORS 하나만 테스트로 고정하자. 내일 body limit, 모레 static path를 보면 된다.

7. 더 볼 자료

중복 회피 메모

로컬 content/generated와 Supabase의 최근 web_app_dev 글을 확인했다. 최근 글은 Supabase RLS Tester, Supabase Auth 이메일, Next.js AI 디버깅, PWA 설치 수명주기, Expo/React Native 운영, Next.js 인증 경계를 다뤘다. 이번 글은 Supabase나 Next.js 인증 경계를 반복하지 않고, 2026년 6월 9일 Hono v4.12.25 보안 릴리스를 바탕으로 CORS, body size, static file serving, serverless adapter가 실제 입력 계약과 런타임 경계에서 어떻게 깨질 수 있는지에 집중했다.

댓글 0

최신순 ▾
한 줄 남기기

혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)