~/web-app-dev.md
WEB_APP_DEV

SSR에서 request context는 전역 변수가 아니라 격리된 렌더 상태다

10분 읽기·2026.06.25·출처 4·00
오늘의 질문

Hono JSX처럼 가벼운 서버 렌더링에서도 왜 요청별 context 격리와 출력 escaping을 별도 계약으로 검증해야 할까?

web-app-dev.md
SSR context isolation

SSR에서 request context는 전역 변수가 아니라 격리된 렌더 상태다

1. 왜 지금 봐야 하나

Hono v4.12.27은 세 가지 보안 수정을 담은 릴리스다. 첫째, hono/jsxhono/jsx-renderer에서 서버 사이드 렌더링 중 context가 요청별로 격리되지 않아 동시 요청의 값이 섞일 수 있었다. 둘째, hono/csscx()가 class 이름을 조합하면서 입력을 escape하지 않은 채 이미 escape된 값처럼 표시해 SSR 결과에 XSS가 들어갈 수 있었다. 셋째, AWS API Gateway v1/VPC Lattice adapter가 반복 request header 값을 substring 비교로 de-duplicate하면서 서로 다른 값을 떨어뜨릴 수 있었다.

겉으로 보면 세 버그는 서로 달라 보인다.

  • 하나는 JSX context와 async 렌더링 문제다.
  • 하나는 class attribute escaping 문제다.
  • 하나는 X-Forwarded-For 같은 반복 헤더 처리 문제다.

하지만 웹앱 개발자 관점의 공통점은 분명하다. 서버 런타임에서 “요청 하나의 데이터”라고 믿는 값은 자동으로 안전하지 않다. 전역 상태, pre-escaped 문자열, adapter가 정규화한 header는 모두 편의성 레이어다. 편의성 레이어를 권한 판단이나 사용자별 HTML 생성에 그대로 믿으면, 작은 프레임워크에서도 cross-request leakage, XSS, 잘못된 IP 제한 같은 제품 보안 문제가 된다.

오늘의 핵심 문장: SSR request context는 전역에서 읽는 값이 아니라, 동시성·escaping·adapter 정규화까지 검증해야 하는 요청별 렌더 계약이다.

2. 핵심 개념

서버 렌더링에서 context는 보통 “prop drilling을 피하는 편한 저장소”처럼 느껴진다. 예를 들어 Hono JSX에서 request context나 session을 트리 아래로 전달하고, 깊은 컴포넌트에서 useContext() 또는 useRequestContext()로 읽는 식이다.

zsh — 생존확인.sh
const UserContext = createContext<{ id: string; role: string } | null>(null)

app.get('/dashboard', jsxRenderer(({ children, c }) => {
  const user = c.get('user')
  return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}))

동기 렌더링만 생각하면 이 구조는 단순하다. 요청 A가 들어오면 A의 user를 context에 넣고, 그 HTML을 만든 뒤 끝난다. 문제는 SSR이 더 이상 항상 단일 직선 실행이 아니라는 점이다. async component가 await로 멈추고, 같은 프로세스가 그 사이에 요청 B를 렌더링할 수 있다.

GitHub advisory에 따르면 Hono의 취약 버전에서는 context 값이 요청별 render scope가 아니라 process-wide 구조에 보관됐다. 그래서 async component가 await 이후 context를 읽으면, 다른 concurrent request의 provider 값이 관측될 수 있었다. 영향을 받는 범위는 createContext()/useContext(), jsxRenderer middleware, useRequestContext()이며, 조건은 “context를 await 이후 읽고, 동시에 여러 요청이 렌더링되는 경우”다.

이것은 단순 버그 이상의 설계 교훈을 준다.

  1. request context는 전역 캐시가 아니다. 사용자, tenant, locale, feature flag, 권한 정보는 요청별로 격리돼야 한다.
  2. async boundary는 보안 boundary다. await 전후에 같은 request-local 값이 유지된다는 보장이 필요하다.
  3. SSR output은 이미 보안 결과물이다. HTML에 누구의 이름·권한·데이터가 들어갔는지는 API 응답만큼 중요하다.
  4. adapter 정규화도 입력 처리다. Lambda/API Gateway가 만든 Request 객체가 원래 HTTP 의미를 보존하는지 봐야 한다.

3. 최신 이슈와 연결

이번 Hono v4.12.27 릴리스의 첫 번째 advisory는 cross-request data disclosure를 직접 언급한다. 어떤 사용자가 다른 사용자를 위해 렌더링된 HTML을 받을 수 있고, await 이후 수행된 authorization check가 다른 request의 context로 평가될 수 있다는 설명이다. CVSS 점수는 6.5, affected versions는 >= 4.11.8, < 4.12.27, patched version은 4.12.27이다.

두 번째 advisory는 cx() escaping 우회다. cx()가 문자열을 class 이름으로 조합하면서 결과를 이미 escape된 것처럼 처리했고, 서버 렌더링에서 JSX class attribute에 들어갈 때 일반적인 attribute escaping이 건너뛰어질 수 있었다. untrusted input이 class name으로 들어가는 경우 공격자는 " 같은 문자를 통해 attribute 밖으로 빠져나와 markup을 주입할 수 있다. affected versions는 >= 4.0.0, < 4.12.27이다.

세 번째 advisory는 AWS API Gateway v1 adapter의 반복 헤더 처리다. 같은 header가 여러 번 들어왔을 때 adapter가 exact match가 아니라 substring 비교로 중복 제거를 해서, 예를 들어 203.0.113.1203.0.113.10의 substring이라는 이유로 누락될 수 있었다. 보안 판단이 X-Forwarded-For chain, rate limit, audit logging, proxy-chain validation에 의존한다면 애플리케이션은 클라이언트가 실제로 보낸 것과 다른 입력을 보게 된다. affected versions는 >= 4.3.3, < 4.12.27이다.

최근 웹앱 스택은 “서버에서 HTML을 만들고, 서버에서 action을 처리하고, edge/serverless adapter가 HTTP를 추상화해준다”는 방향으로 움직인다. Next.js, Hono, Remix, Astro 등 프레임워크는 다르지만 공통 리스크는 비슷하다. 서버와 클라이언트의 경계가 흐려질수록 개발자는 “컴포넌트 코드”를 쓰고 있다고 느끼지만, 실제로는 권한이 걸린 HTTP 응답을 만들고 있다.

4. 개발자 관점 해석

작은 팀이 Hono를 쓰는 이유는 명확하다. 빠르고, TypeScript 친화적이고, 런타임 선택지가 넓고, JSX SSR까지 가볍게 붙일 수 있다. 문제는 “가볍다”가 “보안 계약이 없다”는 뜻은 아니라는 점이다.

예를 들어 아래 컴포넌트는 코드 리뷰에서 평범해 보일 수 있다.

zsh — 생존확인.sh
async function AccountBadge() {
  await loadSomethingSlow()
  const user = useContext(UserContext)

  if (user?.role === 'admin') {
    return <a href="/admin">Admin</a>
  }

  return <span>{user?.id}</span>
}

프레임워크가 request context를 제대로 격리한다면 문제없다. 하지만 이번 advisory가 보여준 실패 조건에서는 await 이후 useContext()가 다른 요청의 user를 읽을 수 있었다. 그러면 사용자 A에게 사용자 B의 이름이 보이는 정도에서 끝나지 않는다. role === 'admin' 같은 분기가 들어가면 렌더링된 링크, 버튼, 서버 action URL, CSRF token, tenant-specific 데이터가 잘못 포함될 수 있다.

물론 patched version으로 올리는 것이 첫 번째 대응이다. 하지만 코드 습관도 바꿔야 한다.

요청별 값은 명시적으로 고정한다

깊은 컴포넌트에서 await 이후 request context를 다시 읽는 대신, 권한 판단에 필요한 최소 값을 상위에서 검증하고 props로 내려보내면 리스크가 줄어든다.

zsh — 생존확인.sh
app.get('/dashboard', async (c) => {
  const user = await requireUser(c)
  const canSeeAdmin = user.role === 'admin'

  return c.render(<Dashboard userId={user.id} canSeeAdmin={canSeeAdmin} />)
})

이 패턴은 context를 금지하자는 뜻이 아니다. context는 theme, locale, request id처럼 트리 전체에 필요한 값을 전달하는 데 여전히 유용하다. 다만 권한·소유권·tenant 분리처럼 실패 비용이 큰 값은 “어디서 검증했고, 어떤 값으로 고정했는지”가 보이게 만드는 편이 낫다.

className은 데이터 채널이 아니다

cx() 취약점은 “class name은 코드가 정하는 문자열”이라는 가정이 깨질 때 위험해진다. 사용자가 고른 색상, CMS 필드, 프로필 설정, query string 값을 그대로 className에 합치는 코드는 피해야 한다.

zsh — 생존확인.sh
// 위험한 방향: 외부 입력을 className에 직접 합침
<div class={cx('card', c.req.query('variant'))}>...</div>

대신 외부 입력은 allowlist로 내부 토큰에 매핑한다.

zsh — 생존확인.sh
const variants = {
  default: 'card-default',
  warning: 'card-warning',
  success: 'card-success',
} as const

const raw = c.req.query('variant')
const variant = raw === 'warning' || raw === 'success' ? raw : 'default'

return <div class={cx('card', variants[variant])}>...</div>

escaping bug가 고쳐졌더라도 이 습관은 유효하다. HTML attribute에 들어가는 문자열은 “표현”이 아니라 “문서 구조를 바꿀 수 있는 입력”이다.

header는 원본 의미를 잃을 수 있다

serverless adapter는 HTTP 이벤트를 Web Request 객체로 바꿔준다. 이 변환 과정에서 multi-value header, cookie, path, IP chain 같은 값의 의미가 바뀔 수 있다. 특히 IP 제한이나 rate limiting을 X-Forwarded-For에 의존한다면 다음을 분리해야 한다.

  • edge/proxy가 신뢰할 수 있는 header를 어떻게 설정하는가
  • 애플리케이션이 어떤 header를 읽는가
  • 반복 header가 들어왔을 때 순서와 모든 값이 보존되는가
  • 조작 가능한 client header와 신뢰 가능한 platform metadata를 구분하는가

“adapter가 알아서 표준 Request로 만들었겠지”라고 믿는 순간, 보안 판단의 입력 소스가 흐려진다.

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

Hono 또는 다른 JSX SSR 프레임워크를 운영한다면 오늘 아래 항목을 점검하자.

패키지와 영향 범위

  • hono 버전이 4.12.27 이상인가?
  • hono/jsx, hono/jsx-renderer, useRequestContext()를 SSR에서 쓰는가?
  • async component 안에서 await 이후 useContext()를 호출하는 패턴이 있는가?
  • hono/csscx()에 외부 입력이 들어갈 수 있는가?
  • AWS API Gateway v1 또는 VPC Lattice adapter를 쓰는가?

request context

  • session, tenant id, role, plan, feature flag 같은 값이 request별로 고정되는가?
  • 권한 판단이 깊은 UI 컴포넌트의 context read에 숨어 있지는 않은가?
  • await 이후 context를 다시 읽는 컴포넌트를 grep할 수 있는가?
  • 테스트에서 동시 요청 두 개가 서로 다른 사용자 HTML을 받는지 확인하는가?

SSR escaping

  • 사용자 입력이 class, style, data-*, href, src 같은 attribute에 들어가는가?
  • className으로 받을 수 있는 값은 allowlist mapping을 거치는가?
  • CMS나 DB에서 온 “이미 escape된 HTML”을 JSX에 다시 넣는 경로가 있는가?
  • dangerouslySetInnerHTML 또는 유사 API 사용 지점이 문서화돼 있는가?

adapter와 header

  • IP 제한, rate limit, audit log가 어떤 header를 기준으로 하는가?
  • multi-value header를 사용하는 배포 환경에서 실제 이벤트와 앱 내부 Request가 같은 값을 보는가?
  • proxy chain 검증을 앱 코드가 직접 한다면, platform이 제공하는 더 신뢰 가능한 원본 IP 정보가 있는가?
  • 보안 판단에 쓰는 header는 integration test로 고정돼 있는가?

6. 오늘 10분 액션

  1. 프로젝트에서 hono 버전을 확인한다.
zsh — 생존확인.sh
npm ls hono
# 또는
pnpm why hono
  1. 4.12.27 미만이면 먼저 업그레이드한다.
zsh — 생존확인.sh
pnpm up hono@latest
# 또는
npm install hono@latest
  1. async SSR context 사용 지점을 찾는다.
zsh — 생존확인.sh
rg "useContext|useRequestContext|jsxRenderer" src

찾은 컴포넌트에서 await 이후 context를 읽는 코드가 있으면, 권한 판단에 쓰이는 값부터 상위 route handler에서 검증하고 props로 내려보내는 방식으로 바꾼다.

  1. cx( 사용 지점을 찾고, 외부 입력이 className에 섞이는지 본다.
zsh — 생존확인.sh
rg "cx\(" src

query string, form value, DB/CMS field, user profile setting이 className에 들어간다면 allowlist mapping으로 바꾼다.

  1. AWS API Gateway v1/VPC Lattice에 배포했다면 repeated header 테스트를 하나 만든다. 예를 들어 X-Forwarded-For203.0.113.1203.0.113.10을 함께 넣었을 때 앱이 두 값을 모두 보는지 확인한다.

오늘의 목표는 완벽한 보안 감사가 아니다. “내 SSR 코드가 요청별 상태, HTML escaping, adapter 입력을 각각 별도 계약으로 보고 있는가?”를 확인하는 것이다.

7. 더 볼 자료

  • Hono v4.12.27 릴리스: 세 보안 수정과 영향을 받는 패키지 확인
  • GHSA-hvrm-45r6-mjfj: async SSR 중 request context가 섞이는 조건과 영향 확인
  • GHSA-w62v-xxxg-mg59: class attribute escaping이 왜 XSS로 이어지는지 확인
  • GHSA-xgm2-5f3f-mvvc: 반복 header 값 누락이 IP 제한·rate limit·audit log에 주는 영향 확인

중복 회피 메모

로컬 content/generated와 Supabase 최근 web_app_dev 글을 확인했다. 최근 글은 Server Actions의 POST 요청 계약, npm trusted publishing/OIDC provenance, Supabase OAuth/RLS 위임 권한, WebMCP 브라우저 도구 계약, View Transition 상태 변화 계약, Supabase 브랜칭/pg-delta, React Compiler, Next.js Cache Components, Hono v4.12.25의 CORS/body-limit/static-file/serverless adapter 보안 수정을 다뤘다. 이번 글은 Hono를 다시 다루지만 이전 Hono 글의 CORS·body size·Windows path traversal·Set-Cookie 문제가 아니라, Hono v4.12.27의 JSX SSR request context 격리, cx() attribute escaping, repeated header 보존을 바탕으로 “요청별 렌더 상태와 adapter 입력을 어떻게 검증할 것인가”에 집중했다.

오늘 10분 액션+5%

댓글 0

최신순 ▾
한 줄 남기기

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