Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(middleware): Introduce IP Restriction Middleware #2813

Merged
merged 43 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
64d0d49
feat: Introduce IP Limit Middleware
nakasyou May 25, 2024
1674745
chore(jsr): add return types
nakasyou May 25, 2024
6e5c599
chore: format code
nakasyou May 25, 2024
9e97cd5
Merge remote-tracking branch 'upstream/main' into feat/ip-limit
nakasyou May 27, 2024
6452da7
fix: eslint
nakasyou May 27, 2024
f494dc6
feat: if allow is empty, set allow at * by default (#3)
EdamAme-x May 29, 2024
dfb42f8
feat: remove wildcard
nakasyou Jun 6, 2024
c3c9b8b
chore: fix spelling
nakasyou Jun 6, 2024
e13765f
chore: format
nakasyou Jun 6, 2024
336751b
chore: sort imports
nakasyou Jun 6, 2024
b9afd56
fix: test
nakasyou Jun 6, 2024
f17b471
chore: sort imports
nakasyou Jun 7, 2024
5c6eeb3
feat: renamed `ipLimit` to `ipRestriction`
nakasyou Jun 18, 2024
9390427
feat: accept `(c: Context) => string)`
nakasyou Jun 18, 2024
15a1fe2
chore: format code
nakasyou Jun 18, 2024
2cfe942
feat: allow/deny -> allowList/denyList
nakasyou Jun 22, 2024
2e92e80
feat: suport function rule
nakasyou Jun 23, 2024
612a5bd
chore: format code
nakasyou Jun 23, 2024
c7b8303
fix: test code
nakasyou Jun 23, 2024
aedc959
feat: suport custom errors
nakasyou Jun 23, 2024
1e658e5
fix: test code
nakasyou Jun 23, 2024
7bf138f
fix: name in test code
nakasyou Jun 23, 2024
5808d5a
feat: allow function to named function
nakasyou Jun 23, 2024
1fe9a3f
perf(ip-restriction): optimize ip-restriction middleware by prepare m…
usualoma Jun 23, 2024
f5d7ad1
feat: don't use random ip in test
nakasyou Jun 23, 2024
c060ab5
chore: ipVn to ipvn
nakasyou Jun 23, 2024
28ca4d2
fix: test code
nakasyou Jun 23, 2024
f691da2
Merge remote-tracking branch 'remotes/nakasyou/feat/ip-limit' into pe…
usualoma Jun 23, 2024
163295e
fix: fix type error in ip-restriction middleware test
usualoma Jun 23, 2024
723743a
chore: rename `IPRestrictRule` to `IPRestrictionRule`
nakasyou Jun 24, 2024
fc7e671
Merge branch 'feat/ip-limit' into perf/prepare-ip-restriction-rule
usualoma Jun 24, 2024
3824549
docs(ip-restriction): add a comment to explain the normalization of I…
usualoma Jun 24, 2024
095c0dd
docs(ip-restriction): fix typo in comment
usualoma Jun 24, 2024
33696fb
refactor(ip-restriction): rename convertIPv6ToString to convertIPv6Bi…
usualoma Jun 25, 2024
9b84e72
Merge pull request #4 from usualoma/perf/prepare-ip-restriction-rule
nakasyou Jun 25, 2024
6bf7f7a
feat: support to receive `Context` in `onError`
nakasyou Jul 6, 2024
b9d52b7
fix: https://github.com/honojs/hono/pull/2813#discussion_r1667327721
nakasyou Jul 6, 2024
3a9e099
fix: format code
nakasyou Jul 6, 2024
74dcca4
feat: use `Forbidden`
nakasyou Jul 6, 2024
ddd7c70
Merge remote-tracking branch 'upstream/next' into feat/ip-limit
yusukebe Jul 8, 2024
c787e89
Merge remote-tracking branch 'upstream/next' into feat/ip-limit
yusukebe Jul 8, 2024
33b39ac
tracking the `next`
yusukebe Jul 8, 2024
28ce298
remove importing `HonoRequest`
yusukebe Jul 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"./basic-auth": "./src/middleware/basic-auth/index.ts",
"./bearer-auth": "./src/middleware/bearer-auth/index.ts",
"./body-limit": "./src/middleware/body-limit/index.ts",
"./ip-restriction": "./src/middleware/ip-restriction/index.ts",
"./cache": "./src/middleware/cache/index.ts",
"./cookie": "./src/helper/cookie/index.ts",
"./accepts": "./src/helper/accepts/index.ts",
Expand Down Expand Up @@ -93,7 +94,8 @@
"./utils/mime": "./src/utils/mime.ts",
"./utils/stream": "./src/utils/stream.ts",
"./utils/types": "./src/utils/types.ts",
"./utils/url": "./src/utils/url.ts"
"./utils/url": "./src/utils/url.ts",
"./utils/ipaddr": "./src/utils/ipaddr.ts"
},
"publish": {
"include": [
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"import": "./dist/middleware/body-limit/index.js",
"require": "./dist/cjs/middleware/body-limit/index.js"
},
"./ip-restriction": {
"types": "./dist/types/middleware/ip-restriction/index.d.ts",
"import": "./dist/middleware/ip-restriction/index.js",
"require": "./dist/cjs/middleware/ip-restriction/index.js"
},
"./cache": {
"types": "./dist/types/middleware/cache/index.d.ts",
"import": "./dist/middleware/cache/index.js",
Expand Down Expand Up @@ -385,6 +390,9 @@
"body-limit": [
"./dist/types/middleware/body-limit"
],
"ip-restriction": [
"./dist/types/middleware/ip-restriction"
],
"cache": [
"./dist/types/middleware/cache"
],
Expand Down
114 changes: 114 additions & 0 deletions src/middleware/ip-restriction/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Hono } from '../../hono'
import { Context } from '../../context'
import type { AddressType, GetConnInfo } from '../../helper/conninfo'
import { ipRestriction } from '.'
import type { IPRestrictionRule } from '.'

describe('ipRestriction middleware', () => {
it('Should restrict', async () => {
const getConnInfo: GetConnInfo = (c) => {
return {
remote: {
address: c.env.ip,
},
}
}
const app = new Hono<{
Bindings: {
ip: string
}
}>()
app.use(
'/basic',
ipRestriction(getConnInfo, {
allowList: ['192.168.1.0', '192.168.2.0/24'],
denyList: ['192.168.2.10'],
})
)
app.get('/basic', (c) => c.text('Hello World!'))

app.use(
'/allow-empty',
ipRestriction(getConnInfo, {
denyList: ['192.168.1.0'],
})
)
app.get('/allow-empty', (c) => c.text('Hello World!'))

expect((await app.request('/basic', {}, { ip: '0.0.0.0' })).status).toBe(403)

expect((await app.request('/basic', {}, { ip: '192.168.1.0' })).status).toBe(200)

expect((await app.request('/basic', {}, { ip: '192.168.2.5' })).status).toBe(200)
expect((await app.request('/basic', {}, { ip: '192.168.2.10' })).status).toBe(403)

expect((await app.request('/allow-empty', {}, { ip: '0.0.0.0' })).status).toBe(200)

expect((await app.request('/allow-empty', {}, { ip: '192.168.1.0' })).status).toBe(403)

expect((await app.request('/allow-empty', {}, { ip: '192.168.2.5' })).status).toBe(200)
expect((await app.request('/allow-empty', {}, { ip: '192.168.2.10' })).status).toBe(200)
})
it('Custom onerror', async () => {
const res = await ipRestriction(
() => '0.0.0.0',
{ denyList: ['0.0.0.0'] },
() => new Response('error')
)(new Context(new Request('http://localhost/')), async () => void 0)
expect(res).toBeTruthy()
if (res) {
expect(await res.text()).toBe('error')
}
})
})

describe('isMatchForRule', () => {
const isMatch = async (info: { addr: string; type: AddressType }, rule: IPRestrictionRule) => {
const middleware = ipRestriction(
() => ({
remote: {
address: info.addr,
addressType: info.type,
},
}),
{
allowList: [rule],
}
)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await middleware(undefined as any, () => Promise.resolve())
} catch (e) {
return false
}
return true
}

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()
expect(await isMatch({ addr: '::0', type: 'IPv6' }, '*')).toBeTruthy()
})
it('CIDR Notation', async () => {
expect(await isMatch({ addr: '192.168.2.0', type: 'IPv4' }, '192.168.2.0/24')).toBeTruthy()
expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.0/24')).toBeTruthy()
expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.1/32')).toBeTruthy()
expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.2/32')).toBeFalsy()

expect(await isMatch({ addr: '::0', type: 'IPv6' }, '::0/1')).toBeTruthy()
})
it('Static Rules', async () => {
expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.1')).toBeTruthy()
expect(await isMatch({ addr: '1234::5678', type: 'IPv6' }, '1234::5678')).toBeTruthy()
})
it('Function Rules', async () => {
expect(await isMatch({ addr: '0.0.0.0', type: 'IPv4' }, () => true)).toBeTruthy()
expect(await isMatch({ addr: '0.0.0.0', type: 'IPv4' }, () => false)).toBeFalsy()

const ipaddr = '93.184.216.34'
await isMatch({ addr: ipaddr, type: 'IPv4' }, (ip) => {
expect(ipaddr).toBe(ip.addr)
return false
})
})
})
178 changes: 178 additions & 0 deletions src/middleware/ip-restriction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Middleware for restrict IP Address
* @module
*/

import type { Context, MiddlewareHandler } from '../..'
import type { AddressType, GetConnInfo } from '../../helper/conninfo'
import { HTTPException } from '../../http-exception'
import {
convertIPv4ToBinary,
convertIPv6BinaryToString,
convertIPv6ToBinary,
distinctRemoteAddr,
} from '../../utils/ipaddr'

/**
* Function to get IP Address
*/
type GetIPAddr = GetConnInfo | ((c: Context) => string)

/**
* ### IPv4 and IPv6
* - `*` match all
*
* ### IPv4
* - `192.168.2.0` static
* - `192.168.2.0/24` CIDR Notation
*
* ### IPv6
* - `::1` static
* - `::1/10` CIDR Notation
*/
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 buildMatcher = (
rules: IPRestrictionRule[]
): ((addr: { addr: string; type: AddressType; isIPv4: boolean }) => boolean) => {
const functionRules: IPRestrictionRuleFunction[] = []
const staticRules: Set<string> = new Set()
const cidrRules: [boolean, bigint, bigint][] = []

for (let rule of rules) {
if (rule === '*') {
return () => true
} else if (typeof rule === 'function') {
functionRules.push(rule)
} else {
if (IS_CIDR_NOTATION_REGEX.test(rule)) {
const splittedRule = rule.split('/')

const addrStr = splittedRule[0]
const type = distinctRemoteAddr(addrStr)
if (type === undefined) {
throw new TypeError(`Invalid rule: ${rule}`)
}

Check warning on line 57 in src/middleware/ip-restriction/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L56-L57

Added lines #L56 - L57 were not covered by tests

const isIPv4 = type === 'IPv4'
const prefix = parseInt(splittedRule[1])

if (isIPv4 ? prefix === 32 : prefix === 128) {
// this rule is a static rule
rule = addrStr
} else {
const addr = (isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary)(addrStr)
const mask = ((1n << BigInt(prefix)) - 1n) << BigInt((isIPv4 ? 32 : 128) - prefix)

cidrRules.push([isIPv4, addr & mask, mask] as [boolean, bigint, bigint])
continue
}
}

const type = distinctRemoteAddr(rule)
if (type === undefined) {
throw new TypeError(`Invalid rule: ${rule}`)

Check warning on line 76 in src/middleware/ip-restriction/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L76

Added line #L76 was not covered by tests
}
staticRules.add(
type === 'IPv4'
? rule // IPv4 address is already normalized, so it is registered as is.
: convertIPv6BinaryToString(convertIPv6ToBinary(rule)) // normalize IPv6 address (e.g. 0000:0000:0000:0000:0000:0000:0000:0001 => ::1)
)
}
}

return (remote: {
addr: string
type: AddressType
isIPv4: boolean
binaryAddr?: bigint
}): boolean => {
if (staticRules.has(remote.addr)) {
return true
}
for (const [isIPv4, addr, mask] of cidrRules) {
if (isIPv4 !== remote.isIPv4) {
continue
}

Check warning on line 98 in src/middleware/ip-restriction/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L97-L98

Added lines #L97 - L98 were not covered by tests
const remoteAddr = (remote.binaryAddr ||= (
isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary
)(remote.addr))
if ((remoteAddr & mask) === addr) {
return true
}
}
for (const rule of functionRules) {
if (rule({ addr: remote.addr, type: remote.type })) {
return true
}
}
return false
}
}

/**
* Rules for IP Limit Middleware
*/
export interface IPRestrictionRules {
denyList?: IPRestrictionRule[]
allowList?: IPRestrictionRule[]
}

/**
* IP Limit Middleware
*
* @param getIP function to get IP Address
*/
export const ipRestriction = (
getIP: GetIPAddr,
{ denyList = [], allowList = [] }: IPRestrictionRules,
onError?: (
remote: { addr: string; type: AddressType },
c: Context
) => Response | Promise<Response>
): MiddlewareHandler => {
const allowLength = allowList.length

const denyMatcher = buildMatcher(denyList)
const allowMatcher = buildMatcher(allowList)

const blockError = (c: Context): HTTPException =>
new HTTPException(403, {
res: c.text('Forbidden', {
status: 403,
}),
})

return async function (c, next) {
const connInfo = getIP(c)
const addr = typeof connInfo === 'string' ? connInfo : connInfo.remote.address
if (!addr) {
throw blockError(c)
}

Check warning on line 153 in src/middleware/ip-restriction/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L152-L153

Added lines #L152 - L153 were not covered by tests
const type =
(typeof connInfo !== 'string' && connInfo.remote.addressType) || distinctRemoteAddr(addr)

const remoteData = { addr, type, isIPv4: type === 'IPv4' }

if (denyMatcher(remoteData)) {
if (onError) {
return onError({ addr, type }, c)
}
throw blockError(c)
}
if (allowMatcher(remoteData)) {
return await next()
}

if (allowLength === 0) {
return await next()
} else {
if (onError) {
return await onError({ addr, type }, c)
}

Check warning on line 174 in src/middleware/ip-restriction/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L173-L174

Added lines #L173 - L174 were not covered by tests
throw blockError(c)
}
}
}
61 changes: 61 additions & 0 deletions src/utils/ipaddr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
convertIPv4ToBinary,
convertIPv6BinaryToString,
convertIPv6ToBinary,
distinctRemoteAddr,
expandIPv6,
} from './ipaddr'

describe('expandIPv6', () => {
it('Should result be valid', () => {
expect(expandIPv6('1::1')).toBe('0001:0000:0000:0000:0000:0000:0000:0001')
expect(expandIPv6('::1')).toBe('0000:0000:0000:0000:0000:0000:0000:0001')
expect(expandIPv6('2001:2::')).toBe('2001:0002:0000:0000:0000:0000:0000:0000')
expect(expandIPv6('2001:2::')).toBe('2001:0002:0000:0000:0000:0000:0000:0000')
expect(expandIPv6('2001:0:0:db8::1')).toBe('2001:0000:0000:0db8:0000:0000:0000:0001')
})
})
describe('distinctRemoteAddr', () => {
it('Should result be valud', () => {
expect(distinctRemoteAddr('1::1')).toBe('IPv6')
expect(distinctRemoteAddr('::1')).toBe('IPv6')

expect(distinctRemoteAddr('192.168.2.0')).toBe('IPv4')
expect(distinctRemoteAddr('192.168.2.0')).toBe('IPv4')

expect(distinctRemoteAddr('example.com')).toBeUndefined()
})
})

describe('convertIPv4ToBinary', () => {
it('Should result is valid', () => {
expect(convertIPv4ToBinary('0.0.0.0')).toBe(0n)
expect(convertIPv4ToBinary('0.0.0.1')).toBe(1n)

expect(convertIPv4ToBinary('0.0.1.0')).toBe(1n << 8n)
})
})
describe('convertIPv6ToBinary', () => {
it('Should result is valid', () => {
expect(convertIPv6ToBinary('::0')).toBe(0n)
expect(convertIPv6ToBinary('::1')).toBe(1n)

expect(convertIPv6ToBinary('::f')).toBe(15n)
expect(convertIPv6ToBinary('1234:::5678')).toBe(24196103360772296748952112894165669496n)
})
})

describe('convertIPv6ToString', () => {
// add tons of test cases here
test.each`
input | expected
${'::1'} | ${'::1'}
${'1::'} | ${'1::'}
${'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'}
`('convertIPv6ToString($input) === $expected', ({ input, expected }) => {
expect(convertIPv6BinaryToString(convertIPv6ToBinary(input))).toBe(expected)
})
})
Loading