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 23 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 @@ -87,7 +88,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 @@ -380,6 +385,9 @@
"body-limit": [
"./dist/types/middleware/body-limit"
],
"ip-restriction": [
"./dist/types/middleware/ip-restriction"
],
"cache": [
"./dist/types/middleware/cache"
],
Expand Down
86 changes: 86 additions & 0 deletions src/middleware/ip-restriction/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Hono } from '../../hono'
import { Context } from '../../context'
import { HonoRequest } from '../../request'
import type { GetConnInfo } from '../../helper/conninfo'
import { ipRestriction, isMatchForRule } 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 HonoRequest(new Request('http://localhost/'))), async () => void 0)
expect(res).toBeTruthy()
if (res) {
expect(await res.text()).toBe('error')
}
})
})

describe('isMatchForRule', () => {
it('CIDR Notation', () => {
expect(isMatchForRule({ addr: '192.168.2.0', type: 'IPv4' }, '192.168.2.0/24')).toBeTruthy()
expect(isMatchForRule({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.0/24')).toBeTruthy()

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

const randIP = Math.random().toString()
nakasyou marked this conversation as resolved.
Show resolved Hide resolved
isMatchForRule({ addr: randIP, type: 'IPv4' }, (ip) => {
expect(randIP).toBe(ip.addr)
return false
})
})
})
146 changes: 146 additions & 0 deletions src/middleware/ip-restriction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* 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,
convertIPv6ToBinary,
distinctRemoteAddr,
expandIPv6,
} 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
*/
export type IPRestrictRule = string | ((addr: { addr: string; type: AddressType }) => boolean)
nakasyou marked this conversation as resolved.
Show resolved Hide resolved

const IS_CIDR_NOTATION_REGEX = /\/[0-9]{0,3}$/
export const isMatchForRule = (
remote: {
addr: string
type: AddressType
},
rule: IPRestrictRule
): boolean => {
if (rule === '*') {
// Match all
return true
}

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

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L44-L46

Added lines #L44 - L46 were not covered by tests
if (typeof rule === 'function') {
return rule(remote)
}
if (IS_CIDR_NOTATION_REGEX.test(rule) && (remote.type === 'IPv4' || remote.type === 'IPv6')) {
const isIPv4 = remote.type === 'IPv4'

const splitedRule = rule.split('/')
// CIDR
const baseRuleAddr = splitedRule[0]
if (distinctRemoteAddr(baseRuleAddr) !== remote.type) {
return false
}

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

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L57 - L58 were not covered by tests
const prefix = parseInt(splitedRule[1])

const addrToBinary = isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary

const baseRuleMask = addrToBinary(baseRuleAddr)
const remoteMask = addrToBinary(remote.addr)
const mask = ((1n << BigInt(prefix)) - 1n) << BigInt((isIPv4 ? 32 : 128) - prefix)

return (remoteMask & mask) === (baseRuleMask & mask)
}

const ruleAddrConnType = distinctRemoteAddr(rule)
if (ruleAddrConnType === 'IPv4' || ruleAddrConnType === 'IPv6') {
// Static
if (ruleAddrConnType !== remote.type) {
return false
}

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

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L74-L75

Added lines #L74 - L75 were not covered by tests
if (remote.type === 'IPv6') {
return expandIPv6(remote.addr) === expandIPv6(rule)
}
return rule === remote.addr // IPv4
}
throw new TypeError('Rule is unknown')
}

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

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L81-L82

Added lines #L81 - L82 were not covered by tests

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

/**
* IP Limit Middleware
*
* @param getIP function to get IP Address
*/
export const ipRestriction = (
getIP: GetIPAddr,
{ denyList = [], allowList = [] }: IPRestrictRules,
onError?: (remote: { addr: string; type: AddressType }) => Response | Promise<Response>
nakasyou marked this conversation as resolved.
Show resolved Hide resolved
): MiddlewareHandler => {
const denyLength = denyList.length
const allowLength = allowList.length

const blockError = (): HTTPException =>
nakasyou marked this conversation as resolved.
Show resolved Hide resolved
new HTTPException(403, {
res: new Response('Unauthorized', {
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
status: 403,
}),
})

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

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

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L116-L117

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

for (let i = 0; i < denyLength; i++) {
const isValid = isMatchForRule({ type, addr }, denyList[i])
if (isValid) {
if (onError) {
return onError({ type, addr })
}
throw blockError()
}
}
for (let i = 0; i < allowLength; i++) {
const isValid = isMatchForRule({ type, addr }, allowList[i])
if (isValid) {
return await next()
}
}

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

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

View check run for this annotation

Codecov / codecov/patch

src/middleware/ip-restriction/index.ts#L141-L142

Added lines #L141 - L142 were not covered by tests
throw blockError()
}
}
}
39 changes: 39 additions & 0 deletions src/utils/ipaddr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { convertIPv4ToBinary, 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')).toBe('unknown')
})
})

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)
})
})
72 changes: 72 additions & 0 deletions src/utils/ipaddr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Utils for IP Addresses
* @module
*/

import type { AddressType } from '../helper/conninfo'

/**
* Expand IPv6 Address
* @param ipV6 Shorten IPv6 Address
* @return expanded IPv6 Address
*/
export const expandIPv6 = (ipV6: string): string => {
nakasyou marked this conversation as resolved.
Show resolved Hide resolved
const sections = ipV6.split(':')
for (let i = 0; i < sections.length; i++) {
const node = sections[i]
if (node !== '') {
sections[i] = node.padStart(4, '0')
} else {
sections[i + 1] === '' && sections.splice(i + 1, 1)
sections[i] = new Array(8 - sections.length + 1).fill('0000').join(':')
}
}
return sections.join(':')
}

const IPV4_REGEX = /^[0-9]{0,3}\.[0-9]{0,3}\.[0-9]{0,3}\.[0-9]{0,3}$/

/**
* Distinct Remote Addr
* @param remoteAddr Remote Addr
*/
export const distinctRemoteAddr = (remoteAddr: string): AddressType => {
if (IPV4_REGEX.test(remoteAddr)) {
return 'IPv4'
}
if (remoteAddr.includes(':')) {
// Domain can't include `:`
return 'IPv6'
}
return 'unknown'
}

/**
* 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
}

/**
* Convert IPv6 to Uint8Array
* @param ipV6 IPv6 Address
* @returns BigInt
*/
export const convertIPv6ToBinary = (ipV6: string): bigint => {
const sections = expandIPv6(ipV6).split(':')
let result = 0n
for (let i = 0; i < 8; i++) {
result <<= 16n
result += BigInt(parseInt(sections[i], 16))
}
return result
}