Skip to content

Commit

Permalink
check for frame ip safety (#1036)
Browse files Browse the repository at this point in the history
* check for ip safety

* add jest

* add gh workflow

* middleware->frame calls with dns lookup
  • Loading branch information
JFrankfurt authored Oct 4, 2024
1 parent 3d1e8dd commit d5b4050
Show file tree
Hide file tree
Showing 10 changed files with 433 additions and 7 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/main.yml
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
49 changes: 48 additions & 1 deletion apps/web/app/frames/route.tsx
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.

Copy link
@tatiansa

tatiansa Oct 30, 2024

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 });
}

This comment has been minimized.

Copy link
@tatiansa

tatiansa Oct 30, 2024

R25 really

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);
17 changes: 17 additions & 0 deletions apps/web/jest.config.js
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);
1 change: 1 addition & 0 deletions apps/web/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('@testing-library/jest-dom');
7 changes: 7 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"start": "node --require ./tracer/initialize.js ./node_modules/.bin/next start",
"build": "next build",
"test": "jest",
"analyze": "ANALYZE=true next build",
"dev": "node --require ./tracer/initialize.js ./node_modules/.bin/next dev",
"lint": "next lint",
Expand Down Expand Up @@ -39,6 +40,7 @@
"ethers": "5.7.2",
"framer-motion": "^8.5.5",
"hls.js": "^1.5.14",
"ipaddr.js": "^2.2.0",
"is-ipfs": "^8.0.4",
"jose": "^5.4.1",
"jsonwebtoken": "^9.0.2",
Expand Down Expand Up @@ -67,6 +69,9 @@
"wagmi": "^2.11.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.13",
"@types/node": "18.11.18",
"@types/pg": "^8.11.6",
"@types/react": "^18",
Expand All @@ -76,9 +81,11 @@
"csv-parser": "^3.0.0",
"dotenv": "^16.0.3",
"eslint-config-next": "^13.1.6",
"jest": "^29.7.0",
"postcss": "^8.4.21",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwindcss": "^3.2.4",
"ts-jest": "^29.2.5",
"typescript": "5.0.4"
}
}
148 changes: 148 additions & 0 deletions apps/web/src/middleware/ipSafe.spec.ts
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);
});
});
58 changes: 58 additions & 0 deletions apps/web/src/middleware/ipSafe.ts
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;
}
3 changes: 2 additions & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"name": "next"
}
],
"strictNullChecks": true
"strictNullChecks": true,
"types": ["jest", "@testing-library/jest-dom"]
},
"include": [
"global.d.ts",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"build": "yarn workspaces foreach run build",
"lint": "yarn workspaces foreach run lint",
"test": "yarn workspaces foreach run test",
"postinstall": "sh -c 'if [ command -v ./node_modules/.bin/husky ]; then ./node_modules/.bin/husky install; fi;'",
"prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable"
Expand All @@ -22,6 +23,7 @@
"@graphql-eslint/eslint-plugin": "^3.10.4",
"@swc/core": "^1.2.173",
"@swc/jest": "0.2.20",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "14.0.0",
"@types/jest": "^29.4.0",
"@types/node": "18.14.2",
Expand Down
Loading

0 comments on commit d5b4050

Please sign in to comment.