Hono v4.12.32의 `Object.create(null)`은 작은 API도 입력 객체 경계를 설계해야 한다는 신호다
요청에서 온 query, header, param 객체를 그냥 `{}`처럼 믿어도 될까?
Hono v4.12.32의 Object.create(null)은 작은 API도 입력 객체 경계를 설계해야 한다는 신호다
- 카테고리: web_app_dev
- 예상 읽기 시간: 10분
- 오늘의 질문: 요청에서 온 query, header, param 객체를 그냥
{}처럼 믿어도 될까? - 핵심 출처:
- honojs/hono v4.12.32 release - 게시일: 2026-07-24, 확인일: 2026-07-25
- honojs/hono PR #5161: use Object.create(null) when parsing query, headers, and params - 병합일: 2026-07-24, 확인일: 2026-07-25
- HonoRequest - Hono Docs - 문서, 확인일: 2026-07-25
- JavaScript prototype pollution - MDN - 문서, 확인일: 2026-07-25
- Object.create() - MDN - 문서, 확인일: 2026-07-25
1. 왜 지금 봐야 하나
Hono v4.12.32 릴리스에는 겉보기엔 아주 작은 수정이 들어갔다. release note는 query, headers, params를 파싱할 때 일반 객체 대신 Object.create(null)을 쓰도록 바꿨다고 적는다. 같은 릴리스에는 AWS Lambda authorizer 타입, SSE Last-Event-ID reset, secure-headers의 CSP callback scope 수정도 포함됐다.
오늘은 그중 Object.create(null)에 집중해보자. 이 변화는 “Hono에 취약점이 터졌다”는 이야기가 아니다. PR #5161 설명과 리뷰 흐름은 오히려 반대에 가깝다. 요청에 __proto__ 키가 들어오면 반환 객체의 prototype이 바뀔 수 있어서, __proto__를 일반 키처럼 보존하기 위해 null-prototype object를 쓰는 코드 품질·하드닝 수정이라고 설명한다. 작성자는 Object.prototype 자체를 오염시키는 prototype pollution은 아니라고 선을 그었다.
그럼에도 웹앱 개발자가 이 이슈를 봐야 하는 이유가 있다. Hono 같은 작은 TypeScript 서버에서도 요청 입력은 대부분 “문자열 key-value record”로 들어온다. c.req.query(), c.req.header(), c.req.param()은 개발자에게 편한 객체를 돌려준다. 우리는 이 객체를 권한 조건, 필터, 로그, 캐시 키, DB 쿼리 옵션으로 넘긴다. 이때 객체가 실제로 어떤 prototype을 갖고 있는지, inherited property를 읽을 수 있는지, __proto__ 같은 특수 키를 어떻게 처리하는지는 제품 품질과 보안 경계에 영향을 준다.
오늘의 핵심 문장: 요청 입력 객체는 도메인 모델이 아니라 외부 문자열 map이다. 외부 map은 prototype이 없는 record처럼 다루고, 검증을 통과한 뒤에만 내부 타입으로 승격해야 한다.
2. 핵심 개념
JavaScript의 일반 객체 리터럴 {}은 비어 있어 보이지만 완전히 빈 map은 아니다. 보통 다음 prototype 체인을 갖는다.
const normal = {}
Object.getPrototypeOf(normal) === Object.prototype // true
즉 toString, constructor, hasOwnProperty, __proto__ 같은 이름은 평범한 사용자 입력 키와 충돌할 수 있다. MDN의 prototype pollution 문서는 공격자가 제어하는 key로 동적 property assignment를 할 때 __proto__, constructor.prototype 경로가 문제가 될 수 있다고 설명한다. 특히 query string parser처럼 범용으로 key를 받아 객체를 만드는 코드는 allowlist를 알기 어렵기 때문에 더 조심해야 한다.
반면 Object.create(null)은 prototype이 null인 객체를 만든다.
const record = Object.create(null)
Object.getPrototypeOf(record) === null // true
record['__proto__'] = 'literal key'
record['__proto__'] // 'literal key'
이 객체에는 Object.prototype에서 상속받은 메서드가 없다. 장점은 외부 입력 key가 prototype chain과 덜 얽힌다는 점이다. 단점도 있다. record.hasOwnProperty('x')는 동작하지 않는다. 필요하면 Object.hasOwn(record, 'x')나 Object.prototype.hasOwnProperty.call(record, 'x')처럼 써야 한다.
Hono PR #5161의 핵심도 여기에 있다. query, headers, params parser가 반환하는 객체를 null-prototype object로 만들어 __proto__를 특별한 setter처럼 다루지 않고 일반 key처럼 취급한다. PR은 테스트를 추가했고, bundle size는 약 34바이트 늘었다는 리포트도 남아 있다. 작성자는 나중에 공통 유틸로 줄일 수 있다고 언급했다.
3. 최신 이슈와 연결
HonoRequest 문서를 보면 c.req는 표준 Web Request를 감싼 객체이고, 개발자가 자주 쓰는 입력 helper를 제공한다.
app.get('/search', (c) => {
const query = c.req.query()
const headers = c.req.header()
const params = c.req.param()
return c.json({ query, headers, params })
})
문서상 query()는 전체 query parameters를, header()는 전체 headers record를, param()은 route params를 가져올 수 있다. 이런 helper는 편하지만, 반환값이 “검증된 도메인 타입”이라는 뜻은 아니다. 아직은 외부에서 온 문자열 map이다.
v4.12.32의 수정은 이 경계를 프레임워크 내부에서 한 단계 더 안전하게 만든다. 하지만 애플리케이션 코드가 해야 할 일은 여전히 남아 있다.
예를 들어 다음 코드는 위험한 습관을 만든다.
app.get('/orders', async (c) => {
const filters = c.req.query()
return c.json(await listOrders(filters))
})
겉으로는 간결하다. 하지만 filters 안에는 어떤 key가 들어올지 모른다. sort, limit, status만 기대했는데 __proto__, constructor, admin, includeDeleted, tenant_id 같은 key가 섞일 수 있다. null-prototype object는 prototype 관련 문제를 줄여주지만, “허용되지 않은 필터가 내부 함수로 흘러가는 문제”까지 해결하지는 않는다.
같은 릴리스의 PR #5147도 비슷한 교훈을 준다. secure-headers middleware에서 enforced CSP와 report-only CSP가 함께 있을 때 nonce callback scope가 섞여 500 응답으로 이어질 수 있는 버그를 고쳤다. 이것도 “보안 헤더를 켰다”에서 끝나지 않고, 각 header가 어떤 callback과 어떤 request context를 공유하는지 경계를 분리해야 한다는 이야기다.
4. 개발자 관점 해석
이번 이슈를 웹앱 코드 리뷰 기준으로 바꾸면 세 가지다.
1) 외부 입력 record와 내부 DTO를 분리한다
c.req.query() 결과를 그대로 service 함수에 넘기지 말자. 한 번 더 파싱해서 내부 타입으로 바꿔야 한다.
type OrderFilter = {
status?: 'paid' | 'failed' | 'refunded'
limit: number
}
function parseOrderFilter(input: Record<string, string | undefined>): OrderFilter {
const status = input.status
const limit = Number(input.limit ?? 20)
return {
status: status === 'paid' || status === 'failed' || status === 'refunded' ? status : undefined,
limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 100) : 20,
}
}
핵심은 input의 모든 key를 신뢰하지 않고, 필요한 key만 꺼내서 새 객체를 만든다는 점이다. 새 객체는 도메인 타입이고, 나머지는 버린다.
2) for...in보다 own property 기준을 명시한다
외부 객체를 순회해야 한다면 inherited property를 읽지 않는 습관이 중요하다.
for (const key of Object.keys(input)) {
// own enumerable property만 순회
}
if (Object.hasOwn(input, 'status')) {
// 명시적으로 own key 확인
}
null-prototype object에서는 input.hasOwnProperty가 없을 수 있다. 이 점은 버그가 아니라 의도된 특성이다. 그래서 Object.hasOwn처럼 객체 바깥의 정적 helper를 쓰는 편이 낫다.
3) 보안 fix가 아니라도 regression test를 남긴다
PR #5161은 “security fix는 아니지만 just to be safe”에 가까운 하드닝이다. 실제 제품 코드에서도 이런 종류의 테스트가 필요하다.
it('treats __proto__ query key as plain input', async () => {
const res = await app.request('/search?__proto__=x&status=paid')
expect(res.status).toBe(200)
const body = await res.json()
expect(body.filter.status).toBe('paid')
expect(Object.prototype).not.toHaveProperty('x')
})
테스트의 목적은 특정 프레임워크를 의심하는 것이 아니다. “우리 서비스의 입력 경계가 예상하지 못한 key를 만나도 도메인 로직으로 승격하지 않는다”는 계약을 남기는 것이다.
5. 내 프로젝트에 적용할 체크포인트
- Hono/Express/Fastify/Next Route Handler에서
req.query,searchParams,headers,params를 그대로 service 함수에 넘기는 곳이 있는가? - 필터·정렬·페이지네이션 object를 만들 때 allowlist 없이 spread하고 있지 않은가?
// 피하기
const options = { ...defaults, ...c.req.query() }
// 선호
const options = parseListOptions(c.req.query())
Object.assign, deep merge, config merge에 사용자 입력 객체가 들어가는가?__proto__,constructor,prototype같은 key를 포함한 요청을 테스트해본 적이 있는가?hasOwnProperty를 직접 호출하는 코드가 null-prototype object를 만나도 안전한가?- CSP, CORS, secure headers처럼 “보안 미들웨어 설정 객체”를 request별 callback과 섞을 때 header별 scope가 분리되어 있는가?
- AI 코딩 에이전트에게 “query object를 그대로 DB where 조건으로 넘기지 말라”는 규칙을 준 적이 있는가?
작은 팀에서는 이 체크리스트를 “보안팀의 일”로 미루기 쉽다. 하지만 실제로는 타입 설계와 API 설계의 일이다. 외부 입력을 내부 타입으로 승격하는 경계가 명확하면 보안뿐 아니라 디버깅, 테스트, AI 코드 리뷰 품질도 좋아진다.
6. 오늘 10분 액션
- 프로젝트에서
req.query,c.req.query(),searchParams,Object.assign,{ ...query }를 검색한다. - 그중 사용자 요청 → service 함수 → DB 조건으로 이어지는 경로 하나를 고른다.
- 허용할 key를 3~5개만 적고, 나머지는 버리는
parseXxxInput()함수를 만든다. __proto__,constructor,prototype, 예상하지 않은 key를 넣는 테스트를 하나 추가한다.- AI 코딩 도구 지침에 다음 문장을 추가한다.
요청 query/header/param 객체는 외부 문자열 map이다. 그대로 내부 DTO, DB filter, config merge에 넘기지 말고 allowlist parser를 통과시켜라. own property 확인은
Object.hasOwn을 사용하라.
7. 더 볼 자료
- honojs/hono v4.12.32 release
- PR #5161: use Object.create(null) when parsing query, headers, and params
- HonoRequest - Hono Docs
- JavaScript prototype pollution - MDN
- Object.create() - MDN
- Hono secureHeaders middleware
중복 회피 메모
로컬 content/generated와 Supabase 최근 web_app_dev 글을 확인했다. 최근 글은 Next.js Partial Prefetching/App Shell, Supabase Pipelines/CDC 분석 분리, Next.js 월간 보안 릴리스, Next.js SWC WASM/lockfile 배포 계약, React Router Web Streams 런타임, TypeScript 7 빌드 계약, Next.js 에이전트 검증 루프, pg_graphql introspection, PostgREST JWT kid, Supabase Auth URL 라우팅을 다뤘다. 2026-06-11 글에서 Hono 보안 릴리스를 미들웨어 입력 계약으로 다룬 적은 있지만, 이번 글은 새 Hono v4.12.32 릴리스의 Object.create(null) 파싱 변경을 바탕으로 query/header/param 반환 객체, null-prototype record, own property 확인, 외부 입력 DTO 승격 경계를 다룬다.
핵심 출처
로그인하면 이 글을 북마크하고, 나만 보는 한 줄 메모를 남길 수 있어요.
댓글 0
최신순 ▾혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)