-
Notifications
You must be signed in to change notification settings - Fork 410
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* check for ip safety * add jest * add gh workflow * middleware->frame calls with dns lookup
- Loading branch information
1 parent
3d1e8dd
commit d5b4050
Showing
10 changed files
with
433 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
name: Unit Tests | ||
|
||
on: | ||
push: | ||
branches: | ||
- master | ||
pull_request: | ||
branches: [master] | ||
|
||
jobs: | ||
Jest: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v1 | ||
- name: Run Tests | ||
run: | | ||
yarn install | ||
yarn test |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,48 @@ | ||
export { GET, POST } from '@frames.js/render/next'; | ||
import { GET as getHandler, POST as postHandler } from '@frames.js/render/next'; | ||
import { ipSafe } from 'apps/web/src/middleware/ipSafe'; | ||
import { NextRequest, NextResponse } from 'next/server'; | ||
import ipaddr from 'ipaddr.js'; | ||
import { URL } from 'url'; | ||
import dns from 'dns/promises'; | ||
|
||
function withIPCheck(handler: (req: NextRequest) => Promise<Response>) { | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong. |
||
return async function (req: NextRequest) { | ||
const searchParams = req.nextUrl.searchParams; | ||
const url = searchParams.get('url'); | ||
|
||
if (url) { | ||
try { | ||
const parsedUrl = new URL(url); | ||
const hostname = parsedUrl.hostname; | ||
const resolvedAddresses = await dns.resolve(hostname); | ||
|
||
let allSafe = true; | ||
|
||
for (const address of resolvedAddresses) { | ||
if (ipaddr.isValid(address)) { | ||
if (!ipSafe(address)) { | ||
allSafe = false; | ||
} else { | ||
} | ||
} else { | ||
return NextResponse.json({ message: 'Invalid IP address resolution' }, { status: 400 }); | ||
} | ||
} | ||
|
||
if (!allSafe) { | ||
return NextResponse.json({ message: 'Forbidden: Unsafe IP' }, { status: 403 }); | ||
} | ||
|
||
return await handler(req); | ||
} catch (error) { | ||
return NextResponse.json({ message: 'Invalid URL format' }, { status: 400 }); | ||
} | ||
} | ||
|
||
return handler(req); | ||
}; | ||
} | ||
|
||
// | ||
export const GET = withIPCheck(getHandler); | ||
export const POST = withIPCheck(postHandler); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
const nextJest = require('next/jest'); | ||
|
||
const createJestConfig = nextJest({ | ||
dir: './', | ||
}); | ||
|
||
const customJestConfig = { | ||
testEnvironment: 'jest-environment-jsdom', | ||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], | ||
moduleDirectories: ['node_modules', '<rootDir>'], | ||
moduleNameMapper: { | ||
'^@/components/(.*)$': '<rootDir>/components/$1', | ||
'^@/pages/(.*)$': '<rootDir>/pages/$1', | ||
}, | ||
}; | ||
|
||
module.exports = createJestConfig(customJestConfig); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
require('@testing-library/jest-dom'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import { ipSafe } from './ipSafe'; | ||
import ipaddr from 'ipaddr.js'; | ||
|
||
const badIps = [ | ||
'127.0.0.1', // IPv4 loopback | ||
'::1', // IPv6 loopback | ||
'10.0.0.1', // 10.0.0.0/8 private IP | ||
'172.16.0.13', // 172.16.0.0/12 private IP | ||
'192.168.15.15', // 192.168.0.0/16 private IP | ||
'169.254.169.254', // AWS metadata endpoint | ||
'0.0.0.0', // IPv4 unspecified address | ||
'1.2.3.0/24', // IPv4 network CIDR (won't pass string-based IP checks) | ||
'::ffff:192.0.2.128', // IPv6 mapped address pointing to IPv4 private address | ||
'::ffff:172.16.0.0', // ditto | ||
'::ffff:10.0.0.0', // ditto | ||
'::ffff:169.254.169.254', // ditto, but pointing at AWS metadata address | ||
'::ffff:127.0.0.1', // ditto, but pointing at localhost | ||
'::ffff:c0a8:8b32', // mapped IP address in hex format (192.168.139.50) | ||
'fe80::c800:eff:fe74:8', // IPv6 link-local address | ||
'fd6d:8d64:af0c::', // IPv6 unique local address | ||
'nonsense', // nonsense IP | ||
]; | ||
|
||
jest.mock('ipaddr.js', () => ({ | ||
parse: jest.fn(), | ||
IPv4: { | ||
subnetMaskFromPrefixLength: jest.fn(() => ({ | ||
match: jest.fn(() => true), | ||
})), | ||
}, | ||
IPv6: { | ||
subnetMaskFromPrefixLength: jest.fn(() => ({ | ||
match: jest.fn(() => true), | ||
})), | ||
}, | ||
})); | ||
describe('IP Safe Tests', () => { | ||
let originalEnv: string | undefined; | ||
|
||
beforeAll(() => { | ||
originalEnv = process.env.NODE_ENV; | ||
}); | ||
|
||
afterAll(() => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = originalEnv; | ||
}); | ||
|
||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
badIps.forEach((badIp) => { | ||
test(`returns false for unsafe IP: ${badIp}`, () => { | ||
expect(ipSafe(badIp)).toBe(false); | ||
}); | ||
}); | ||
|
||
test('returns false for invalid IP address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
(ipaddr.parse as jest.Mock).mockImplementationOnce(() => { | ||
throw new Error('Invalid IP'); | ||
}); | ||
|
||
expect(ipSafe('invalid-ip')).toBe(false); | ||
}); | ||
|
||
test('returns false for unsafe IPv4 address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
const mockIPv4 = { | ||
kind: () => 'ipv4', | ||
range: () => 'private', | ||
toString: () => '127.0.0.1', | ||
match: () => true, | ||
}; | ||
(ipaddr.parse as jest.Mock).mockReturnValueOnce(mockIPv4); | ||
|
||
expect(ipSafe('127.0.0.1')).toBe(false); | ||
}); | ||
|
||
test('returns true for safe IPv4 address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
const mockIPv4 = { | ||
kind: () => 'ipv4', | ||
range: () => 'unicast', | ||
toString: () => '8.8.8.8', | ||
match: () => true, | ||
}; | ||
(ipaddr.parse as jest.Mock).mockReturnValueOnce(mockIPv4); | ||
|
||
expect(ipSafe('8.8.8.8')).toBe(true); | ||
}); | ||
|
||
test('returns false for unsafe IPv6 address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
const mockIPv6 = { | ||
kind: () => 'ipv6', | ||
range: () => 'loopback', | ||
isIPv4MappedAddress: () => false, | ||
match: () => true, | ||
}; | ||
(ipaddr.parse as jest.Mock).mockReturnValueOnce(mockIPv6); | ||
|
||
expect(ipSafe('::1')).toBe(false); | ||
}); | ||
|
||
test('returns true for safe IPv6 address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
const mockIPv6 = { | ||
kind: () => 'ipv6', | ||
range: () => 'unicast', | ||
isIPv4MappedAddress: () => false, | ||
match: () => true, | ||
}; | ||
(ipaddr.parse as jest.Mock).mockReturnValueOnce(mockIPv6); | ||
|
||
expect(ipSafe('2001:4860:4860::8888')).toBe(true); | ||
}); | ||
|
||
test('returns true for safe IPv6 mapped IPv4 address', () => { | ||
// @ts-expect-error this is ok I promise | ||
process.env.NODE_ENV = 'production'; | ||
|
||
const mockIPv6 = { | ||
kind: () => 'ipv6', | ||
range: () => 'unicast', | ||
isIPv4MappedAddress: () => true, | ||
toIPv4Address: () => ({ | ||
kind: () => 'ipv4', | ||
range: () => 'unicast', | ||
match: () => true, | ||
}), | ||
}; | ||
(ipaddr.parse as jest.Mock).mockReturnValueOnce(mockIPv6); | ||
|
||
expect(ipSafe('::ffff:8.8.8.8')).toBe(true); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import ipaddr from 'ipaddr.js'; | ||
|
||
export function ipSafe(ipStr: string): boolean { | ||
try { | ||
const ip = ipaddr.parse(ipStr); | ||
if (!ip) { | ||
return false; | ||
} | ||
|
||
const kind = ip.kind(); | ||
if (!kind || (kind !== 'ipv4' && kind !== 'ipv6')) { | ||
return false; | ||
} | ||
try { | ||
if (kind === 'ipv6') { | ||
return ipv6Safe(ip as ipaddr.IPv6); | ||
} | ||
|
||
return ipv4Safe(ip as ipaddr.IPv4); | ||
} catch (e) { | ||
return false; | ||
} | ||
} catch (e) { | ||
return false; | ||
} | ||
} | ||
|
||
function ipv4Safe(ip: ipaddr.IPv4): boolean { | ||
const range = ip.range(); | ||
|
||
// Reject private, link-local, loopback, or broadcast IPs | ||
if (['private', 'linkLocal', 'loopback', 'broadcast'].includes(range)) { | ||
return false; | ||
} | ||
|
||
// Reject special IPs like 0.0.0.0 and non-standard prefixes | ||
if (ip.toString() === '0.0.0.0') { | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
function ipv6Safe(ip: ipaddr.IPv6): boolean { | ||
// If IPv6 address is mapped to an IPv4 address, ensure it's safe | ||
if (ip.isIPv4MappedAddress()) { | ||
const ipv4 = ip.toIPv4Address(); | ||
return ipv4Safe(ipv4); | ||
} | ||
|
||
// Reject loopback, unspecified, link-local, or unique-local IPs | ||
const range = ip.range(); | ||
if (['loopback', 'unspecified', 'linkLocal', 'uniqueLocal'].includes(range)) { | ||
return false; | ||
} | ||
|
||
return true; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Unnecessary Empty else Block: Remove the empty else block after allSafe = false; as it’s redundant.
if (ipaddr.isValid(address)) {
if (!ipSafe(address)) {
allSafe = false;
}
} else {
return NextResponse.json({ message: 'Invalid IP address resolution' }, { status: 400 });
}