CVE-2026-47674
Description
Hono is a Web application framework that provides support for any JavaScript runtime. Prior to 4.12.21, the ip-restriction middleware (hono/ip-restriction) compares incoming IP addresses against configured deny and allow rules using string equality after partial normalization. Non-canonical IPv6 representations of an address already listed in a static rule — such as compressed forms, explicit-zero forms, or hex-notation IPv4-mapped addresses — do not match the normalized rule entry, causing the rule to be silently skipped. This vulnerability is fixed in 4.12.21.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
The Hono ip-restriction middleware prior to 4.12.21 bypasses static IP deny rules when an attacker uses a non-canonical IPv6 address representation.
Vulnerability
The ip-restriction middleware (hono/ip-restriction) in the Hono Web application framework prior to version 4.12.21 compares incoming IP addresses against configured static deny and allow rules using string equality after partial normalization. Non-canonical IPv6 representations of an address already listed in a static rule—such as compressed forms (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), hex-notation IPv4-mapped addresses (::ffff:7f00:1 vs ::ffff:127.0.0.1), or zone identifier suffixes (fe80::1%eth0)—do not match the normalized rule entry, causing the rule to be silently skipped. Invalid IP address strings provided as the remote address are also not rejected and may result in unexpected allow or deny behavior [1].
Exploitation
An attacker needs to send an HTTP request with a source IP address that is syntactically valid but expressed in a non-canonical IPv6 form. If the application uses static (non-CIDR) deny rules in ipRestriction() and the upstream proxy or runtime forwards the remote address in such a form, the middleware's string comparison will fail to match the canonical rule entry and will not apply the restriction. No additional authentication or user interaction is required beyond the network ability to craft the request [1].
Impact
A request from an IP address covered by a static deny rule may bypass the restriction, leading to unauthorized access to endpoints intended to be restricted to specific IP addresses. This bypass can defeat IP-based access controls in environments where the runtime or an upstream proxy provides source addresses in a form that differs from the canonical form used in the rule configuration [1].
Mitigation
The vulnerability is fixed in Hono version 4.12.21. Users should update to this version or later. No workaround other than upgrading is mentioned in the available references; applications using only CIDR-based rules (with a prefix length) are not affected by the static rule bypass but may still be subject to issues with invalid IP strings [1].
AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1c831020fb1faMerge commit from fork
4 files changed · +395 −39
src/middleware/ip-restriction/index.test.ts+76 −3 modified@@ -60,6 +60,60 @@ describe('ipRestriction middleware', () => { expect(await res.text()).toBe('error') } }) + + test.each(['999.999.999.999', '2001:db8::1%eth0', '1234:::5678'])( + 'Should reject invalid remote address: %s', + async (ip) => { + const app = new Hono<{ + Bindings: { + ip: string + } + }>() + app.use( + '/invalid', + ipRestriction( + (c) => ({ + remote: { + address: c.env.ip, + }, + }), + { + allowList: ['127.0.0.1'], + } + ) + ) + app.get('/invalid', (c) => c.text('Hello World!')) + + expect((await app.request('/invalid', {}, { ip })).status).toBe(403) + } + ) + + it('Should not call onError for invalid remote addresses', async () => { + const app = new Hono<{ + Bindings: { + ip: string + } + }>() + app.use( + '/invalid', + ipRestriction( + (c) => ({ + remote: { + address: c.env.ip, + }, + }), + { + allowList: ['127.0.0.1'], + }, + () => new Response('custom error', { status: 418 }) + ) + ) + app.get('/invalid', (c) => c.text('Hello World!')) + + const res = await app.request('/invalid', {}, { ip: '1234:::5678' }) + expect(res.status).toBe(403) + expect(await res.text()).toBe('Forbidden') + }) }) describe('isMatchForRule', () => { @@ -84,6 +138,17 @@ describe('isMatchForRule', () => { return true } + test.each(['192.168.0.0/33', '::/129', '127.0.0.1/', '::ffff:127.0.0.1/129'])( + 'Should throw for invalid CIDR rule: %s', + (rule) => { + expect(() => + ipRestriction(() => '127.0.0.1', { + allowList: [rule], + }) + ).toThrow(`Invalid rule: ${rule}`) + } + ) + it('star', async () => { expect(await isMatch({ addr: '192.168.2.0', type: 'IPv4' }, '*')).toBeTruthy() expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '*')).toBeTruthy() @@ -126,10 +191,18 @@ describe('isMatchForRule', () => { // IPv4-mapped IPv6 addresses are canonicalized to IPv4 expect(await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() expect(await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '127.0.0.2')).toBeFalsy() - // non-dotted IPv4-mapped forms are not canonicalized (treated as IPv6) - expect(await isMatch({ addr: '::ffff:7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeFalsy() - // regular IPv6 is not affected + expect(await isMatch({ addr: '::ffff:7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() + expect(await isMatch({ addr: '0:0:0:0:0:ffff:7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() + // regular IPv6 is matched numerically without being treated as IPv4 expect(await isMatch({ addr: '::1', type: 'IPv6' }, '::1')).toBeTruthy() + expect( + await isMatch({ addr: '2001:db8:0:0:0:0:0:1', type: 'IPv6' }, '2001:db8::1') + ).toBeTruthy() + expect( + await isMatch({ addr: '2001:db8::1', type: 'IPv6' }, '2001:db8:0:0:0:0:0:1') + ).toBeTruthy() + expect(await isMatch({ addr: '::ffff:127.0.0.2', type: 'IPv6' }, '127.0.0.1')).toBeFalsy() + expect(await isMatch({ addr: '::7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeFalsy() }) it('Function Rules', async () => { expect(await isMatch({ addr: '0.0.0.0', type: 'IPv4' }, () => true)).toBeTruthy()
src/middleware/ip-restriction/index.ts+60 −24 modified@@ -6,13 +6,15 @@ import type { Context, MiddlewareHandler } from '../..' import type { AddressType, GetConnInfo } from '../../helper/conninfo' import { HTTPException } from '../../http-exception' +import type { InvalidIPAddressError } from '../../utils/ipaddr' import { convertIPv4MappedIPv6ToIPv4, convertIPv4ToBinary, convertIPv6BinaryToString, convertIPv6ToBinary, distinctRemoteAddr, isIPv4MappedIPv6, + INVALID_IP_ADDRESS_ERROR_CODE, } from '../../utils/ipaddr' /** @@ -35,13 +37,47 @@ type GetIPAddr = GetConnInfo | ((c: Context) => string) type IPRestrictionRuleFunction = (addr: { addr: string; type: AddressType }) => boolean export type IPRestrictionRule = string | ((addr: { addr: string; type: AddressType }) => boolean) -const IS_CIDR_NOTATION_REGEX = /\/[0-9]{0,3}$/ +const IS_CIDR_NOTATION_REGEX = /\/[^/]*$/ +const parseCidrPrefix = (rule: string, prefix: string, max: number): number => { + if (!/^[0-9]{1,3}$/.test(prefix)) { + throw new TypeError(`Invalid rule: ${rule}`) + } + const parsedPrefix = parseInt(prefix) + if (parsedPrefix > max) { + throw new TypeError(`Invalid rule: ${rule}`) + } + return parsedPrefix +} const buildMatcher = ( rules: IPRestrictionRule[] ): ((addr: { addr: string; type: AddressType; isIPv4: boolean }) => boolean) => { const functionRules: IPRestrictionRuleFunction[] = [] const staticRules: Set<string> = new Set() + const staticIPv4Rules: Set<bigint> = new Set() + const staticIPv6Rules: Set<bigint> = new Set() const cidrRules: [boolean, bigint, bigint][] = [] + const registerStaticRule = (rule: string): void => { + const type = distinctRemoteAddr(rule) + if (type === undefined) { + throw new TypeError(`Invalid rule: ${rule}`) + } + if (type === 'IPv4') { + const ipv4binary = convertIPv4ToBinary(rule) + staticRules.add(rule) + staticRules.add(`::ffff:${rule}`) + staticIPv4Rules.add(ipv4binary) + staticIPv6Rules.add((0xffffn << 32n) | ipv4binary) + } else { + const ipv6binary = convertIPv6ToBinary(rule) + const ipv6Addr = convertIPv6BinaryToString(ipv6binary) + staticRules.add(ipv6Addr) + staticIPv6Rules.add(ipv6binary) + if (isIPv4MappedIPv6(ipv6binary)) { + staticRules.add(ipv6Addr.substring(7)) // remove ::ffff: prefix + staticIPv4Rules.add(convertIPv4MappedIPv6ToIPv4(ipv6binary)) + } + } + } for (let rule of rules) { if (rule === '*') { @@ -59,7 +95,7 @@ const buildMatcher = ( } let isIPv4 = type === 'IPv4' - let prefix = parseInt(separatedRule[1]) + let prefix = parseCidrPrefix(rule, separatedRule[1], isIPv4 ? 32 : 128) if (isIPv4 ? prefix === 32 : prefix === 128) { // this rule is a static rule @@ -79,21 +115,7 @@ const buildMatcher = ( } } - const type = distinctRemoteAddr(rule) - if (type === undefined) { - throw new TypeError(`Invalid rule: ${rule}`) - } - if (type === 'IPv4') { - staticRules.add(rule) - staticRules.add(`::ffff:${rule}`) - } else { - const ipv6binary = convertIPv6ToBinary(rule) - const ipv6Addr = convertIPv6BinaryToString(ipv6binary) - staticRules.add(ipv6Addr) - if (isIPv4MappedIPv6(ipv6binary)) { - staticRules.add(ipv6Addr.substring(7)) // remove ::ffff: prefix - } - } + registerStaticRule(rule) } } @@ -115,6 +137,9 @@ const buildMatcher = ( ? remoteAddr : convertIPv4MappedIPv6ToIPv4(remoteAddr) : undefined + if ((remote.isIPv4 ? staticIPv4Rules : staticIPv6Rules).has(remoteAddr)) { + return true + } for (const [isIPv4, addr, mask] of cidrRules) { if (isIPv4) { if (remoteIPv4Addr === undefined) { @@ -221,14 +246,25 @@ export const ipRestriction = ( const remoteData = { addr, type, isIPv4: type === 'IPv4' } - if (denyMatcher(remoteData)) { - if (onError) { - return onError({ addr, type }, c) + try { + if (denyMatcher(remoteData)) { + if (onError) { + return onError({ addr, type }, c) + } + throw blockError(c) } - throw blockError(c) - } - if (allowMatcher(remoteData)) { - return await next() + if (allowMatcher(remoteData)) { + return await next() + } + } catch (e) { + if ( + e instanceof TypeError && + (e as InvalidIPAddressError).code === INVALID_IP_ADDRESS_ERROR_CODE + ) { + // If an invalid IP address is specified, treat it as if no IP address was specified + throw blockError(c) + } + throw e } if (allowLength === 0) {
src/utils/ipaddr.test.ts+40 −2 modified@@ -5,8 +5,20 @@ import { convertIPv6ToBinary, distinctRemoteAddr, expandIPv6, + INVALID_IP_ADDRESS_ERROR_CODE, } from './ipaddr' +const expectInvalidIPAddressError = (fn: () => unknown) => { + try { + fn() + } catch (error) { + expect(error).toBeInstanceOf(TypeError) + expect(error).toHaveProperty('code', INVALID_IP_ADDRESS_ERROR_CODE) + return + } + throw new Error('Expected invalid IP address error') +} + describe('expandIPv6', () => { it('Should result be valid', () => { expect(expandIPv6('1::1')).toBe('0001:0000:0000:0000:0000:0000:0000:0001') @@ -51,6 +63,13 @@ describe('convertIPv4ToBinary', () => { expect(convertIPv4ToBinary('0.0.1.0')).toBe(1n << 8n) }) + + test.each(['1.2.3.256', '1.2.3', '1.2.3.4.5', '1..3.4', '01.2.3.4', 'a.b.c.d'])( + 'Should throw for invalid IPv4: %s', + (input) => { + expectInvalidIPAddressError(() => convertIPv4ToBinary(input)) + } + ) }) describe('convertIPv4ToString', () => { @@ -71,8 +90,26 @@ describe('convertIPv6ToBinary', () => { expect(convertIPv6ToBinary('::1')).toBe(1n) expect(convertIPv6ToBinary('::f')).toBe(15n) - expect(convertIPv6ToBinary('1234:::5678')).toBe(24196103360772296748952112894165669496n) + expect(convertIPv6ToBinary('1234::5678')).toBe(24196103360772296748952112894165669496n) expect(convertIPv6ToBinary('::ffff:127.0.0.1')).toBe(281472812449793n) + expect(convertIPv6ToBinary('fe80::1%eth0')).toBe(convertIPv6ToBinary('fe80::1')) + }) + + test.each([ + '1::2::3', + '1:2:3:4:5:6:7:8:9', + '1:2:3:4:5:6:7', + '12345::', + 'gggg::', + '::ffff:127.0.0.256', + '1234:::5678', + '2001:db8::1%eth0', + '::ffff:127.0.0.1%eth0', + '1:2:3%eth0', + 'gggg%eth0', + 'fe80::1%', + ])('Should throw for invalid IPv6: %s', (input) => { + expectInvalidIPAddressError(() => convertIPv6ToBinary(input)) }) }) @@ -82,11 +119,12 @@ describe('convertIPv6ToString', () => { input | expected ${'::1'} | ${'::1'} ${'1::'} | ${'1::'} - ${'1234:::5678'} | ${'1234::5678'} + ${'1234::5678'} | ${'1234::5678'} ${'2001:2::'} | ${'2001:2::'} ${'2001::db8:0:0:0:0:1'} | ${'2001:0:db8::1'} ${'1234:5678:9abc:def0:1234:5678:9abc:def0'} | ${'1234:5678:9abc:def0:1234:5678:9abc:def0'} ${'::ffff:127.0.0.1'} | ${'::ffff:127.0.0.1'} + ${'fe80::1%eth0'} | ${'fe80::1'} `('convertIPv6ToString($input) === $expected', ({ input, expected }) => { expect(convertIPv6BinaryToString(convertIPv6ToBinary(input))).toBe(expected) })
src/utils/ipaddr.ts+219 −10 modified@@ -35,6 +35,19 @@ export const expandIPv6 = (ipV6: string): string => { const IPV4_OCTET_PART = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])' const IPV4_REGEX = new RegExp(`^(?:${IPV4_OCTET_PART}\\.){3}${IPV4_OCTET_PART}$`) +export const INVALID_IP_ADDRESS_ERROR_CODE = 'ERR_INVALID_IP_ADDRESS' +export type InvalidIPAddressError = TypeError & { + code: typeof INVALID_IP_ADDRESS_ERROR_CODE +} +const CHAR_CODE_0 = 48 +const CHAR_CODE_9 = 57 +const CHAR_CODE_A = 65 +const CHAR_CODE_F = 70 +const CHAR_CODE_a = 97 +const CHAR_CODE_f = 102 +const CHAR_CODE_DOT = 46 +const CHAR_CODE_COLON = 58 +const CHAR_CODE_PERCENT = 37 /** * Distinct Remote Addr @@ -50,19 +63,87 @@ export const distinctRemoteAddr = (remoteAddr: string): AddressType => { } } +const createInvalidIPAddressError = (message: string): InvalidIPAddressError => { + const error = new TypeError(message) as InvalidIPAddressError + error.code = INVALID_IP_ADDRESS_ERROR_CODE + return error +} + +const throwInvalidIPv4Address = (ipv4: string): never => { + throw createInvalidIPAddressError(`Invalid IPv4 address: ${ipv4}`) +} + +const throwInvalidIPv6Address = (ipv6: string): never => { + throw createInvalidIPAddressError(`Invalid IPv6 address: ${ipv6}`) +} + +const parseIPv4ToBinary = ( + ipv4: string, + start: number, + end: number, + onInvalid: () => never +): bigint => { + let result = 0n + let octets = 0 + let octet = 0 + let digits = 0 + let firstDigit = 0 + + for (let i = start; i <= end; i++) { + const code = i < end ? ipv4.charCodeAt(i) : CHAR_CODE_DOT + if (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) { + if (digits === 0) { + firstDigit = code + } else if (firstDigit === CHAR_CODE_0) { + onInvalid() + } + octet = octet * 10 + code - CHAR_CODE_0 + if (octet > 255) { + onInvalid() + } + digits++ + continue + } + + if (code !== CHAR_CODE_DOT || digits === 0 || octets === 4) { + onInvalid() + } + + result = (result << 8n) + BigInt(octet) + octets++ + octet = 0 + digits = 0 + } + + if (octets !== 4) { + onInvalid() + } + + return result +} + +const parseIPv6HexCode = (code: number): number => { + if (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) { + return code - CHAR_CODE_0 + } + if (code >= CHAR_CODE_A && code <= CHAR_CODE_F) { + return code - CHAR_CODE_A + 10 + } + if (code >= CHAR_CODE_a && code <= CHAR_CODE_f) { + return code - CHAR_CODE_a + 10 + } + return -1 +} + +const isIPv6LinkLocal = (ipv6binary: bigint): boolean => ipv6binary >> 118n === 0x3fan + /** * Convert IPv4 to Uint8Array * @param ipv4 IPv4 Address * @returns BigInt */ export const convertIPv4ToBinary = (ipv4: string): bigint => { - const parts = ipv4.split('.') - let result = 0n - for (let i = 0; i < 4; i++) { - result <<= 8n - result += BigInt(parts[i]) - } - return result + return parseIPv4ToBinary(ipv4, 0, ipv4.length, () => throwInvalidIPv4Address(ipv4)) } /** @@ -71,11 +152,139 @@ export const convertIPv4ToBinary = (ipv4: string): bigint => { * @returns BigInt */ export const convertIPv6ToBinary = (ipv6: string): bigint => { - const sections = expandIPv6(ipv6).split(':') + const length = ipv6.length + const sections: number[] = [] + let hasZoneId = false + let compressAt = -1 + let index = 0 + + if (length === 0) { + throwInvalidIPv6Address(ipv6) + } + + while (index < length) { + if (sections.length > 8) { + throwInvalidIPv6Address(ipv6) + } + + let code = ipv6.charCodeAt(index) + + if (code === CHAR_CODE_PERCENT) { + if (index + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + break + } + + if (code === CHAR_CODE_COLON) { + if (index + 1 < length && ipv6.charCodeAt(index + 1) === CHAR_CODE_COLON) { + if (compressAt !== -1) { + throwInvalidIPv6Address(ipv6) + } + compressAt = sections.length + index += 2 + continue + } + throwInvalidIPv6Address(ipv6) + } + + let value = 0 + let digits = 0 + const sectionStart = index + + while (index < length) { + code = ipv6.charCodeAt(index) + const hex = parseIPv6HexCode(code) + if (hex === -1) { + break + } + if (digits === 4) { + throwInvalidIPv6Address(ipv6) + } + value = (value << 4) | hex + digits++ + index++ + } + + if (index < length && ipv6.charCodeAt(index) === CHAR_CODE_DOT) { + let ipv4End = length + for (let i = index; i < length; i++) { + if (ipv6.charCodeAt(i) === CHAR_CODE_PERCENT) { + if (i + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + ipv4End = i + break + } + } + const ipv4 = parseIPv4ToBinary(ipv6, sectionStart, ipv4End, () => + throwInvalidIPv6Address(ipv6) + ) + sections.push(Number((ipv4 >> 16n) & 0xffffn), Number(ipv4 & 0xffffn)) + index = length + break + } + + if (digits === 0) { + throwInvalidIPv6Address(ipv6) + } + + sections.push(value) + + if (index === length) { + break + } + + code = ipv6.charCodeAt(index) + if (code === CHAR_CODE_PERCENT) { + if (index + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + break + } + + if (code !== CHAR_CODE_COLON) { + throwInvalidIPv6Address(ipv6) + } + + if (index + 1 < length && ipv6.charCodeAt(index + 1) === CHAR_CODE_COLON) { + if (compressAt !== -1) { + throwInvalidIPv6Address(ipv6) + } + compressAt = sections.length + index += 2 + continue + } + + index++ + if (index === length) { + throwInvalidIPv6Address(ipv6) + } + } + + if (compressAt === -1 ? sections.length !== 8 : sections.length >= 8) { + throwInvalidIPv6Address(ipv6) + } + let result = 0n - for (let i = 0; i < 8; i++) { + const zeros = compressAt === -1 ? 0 : 8 - sections.length + const firstSectionEnd = compressAt === -1 ? sections.length : compressAt + for (let i = 0; i < firstSectionEnd; i++) { result <<= 16n - result += BigInt(parseInt(sections[i], 16)) + result += BigInt(sections[i]) + } + for (let i = 0; i < zeros; i++) { + result <<= 16n + } + for (let i = firstSectionEnd; i < sections.length; i++) { + result <<= 16n + result += BigInt(sections[i]) + } + if (hasZoneId && !isIPv6LinkLocal(result)) { + throwInvalidIPv6Address(ipv6) } return result }
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
1News mentions
0No linked articles in our index yet.