check for frame ip safety (#1036)

* check for ip safety

* add jest

* add gh workflow

* middleware->frame calls with dns lookup
This commit is contained in:
Jordan Frankfurt
2024-10-04 14:46:32 -05:00
committed by GitHub
parent 3d1e8dd184
commit d5b40500f3
10 changed files with 433 additions and 7 deletions

18
.github/workflows/main.yml vendored Normal file
View File

@@ -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

View File

@@ -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>) {
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
apps/web/jest.config.js Normal file
View File

@@ -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
apps/web/jest.setup.js Normal file
View File

@@ -0,0 +1 @@
require('@testing-library/jest-dom');

View File

@@ -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",
@@ -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",
@@ -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",
@@ -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"
}
}

View File

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

View File

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

View File

@@ -18,7 +18,8 @@
"name": "next"
}
],
"strictNullChecks": true
"strictNullChecks": true,
"types": ["jest", "@testing-library/jest-dom"]
},
"include": [
"global.d.ts",

View File

@@ -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"
@@ -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",

137
yarn.lock
View File

@@ -5,6 +5,13 @@ __metadata:
version: 6
cacheKey: 8
"@adobe/css-tools@npm:^4.4.0":
version: 4.4.0
resolution: "@adobe/css-tools@npm:4.4.0"
checksum: 1f08fb49bf17fc7f2d1a86d3e739f29ca80063d28168307f1b0a962ef37501c5667271f6771966578897f2e94e43c4770fd802728a6e6495b812da54112d506a
languageName: node
linkType: hard
"@adraffy/ens-normalize@npm:1.10.0":
version: 1.10.0
resolution: "@adraffy/ens-normalize@npm:1.10.0"
@@ -368,6 +375,9 @@ __metadata:
"@radix-ui/react-tooltip": ^1.1.2
"@rainbow-me/rainbowkit": ^2.1.5
"@tanstack/react-query": ^5
"@testing-library/jest-dom": ^6.5.0
"@testing-library/react": ^16.0.1
"@types/jest": ^29.5.13
"@types/jsonwebtoken": ^9.0.6
"@types/node": 18.11.18
"@types/pg": ^8.11.6
@@ -389,7 +399,9 @@ __metadata:
ethers: 5.7.2
framer-motion: ^8.5.5
hls.js: ^1.5.14
ipaddr.js: ^2.2.0
is-ipfs: ^8.0.4
jest: ^29.7.0
jose: ^5.4.1
jsonwebtoken: ^9.0.2
kysely: ^0.27.3
@@ -412,6 +424,7 @@ __metadata:
satori: ^0.10.14
sharp: ^0.33.4
tailwindcss: ^3.2.4
ts-jest: ^29.2.5
twemoji: ^14.0.2
typed.js: ^2.1.0
typescript: 5.0.4
@@ -2018,6 +2031,7 @@ __metadata:
"@radix-ui/react-tooltip": ^1.1.2
"@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
@@ -7687,6 +7701,21 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/jest-dom@npm:^6.5.0":
version: 6.5.0
resolution: "@testing-library/jest-dom@npm:6.5.0"
dependencies:
"@adobe/css-tools": ^4.4.0
aria-query: ^5.0.0
chalk: ^3.0.0
css.escape: ^1.5.1
dom-accessibility-api: ^0.6.3
lodash: ^4.17.21
redent: ^3.0.0
checksum: c2d14103ebe3358852ec527ff7512f64207a39932b2f7b6dff7e73ba91296b01a71bad9a9584b6ee010681380a906c1740af50470adc6db660e1c7585d012ebf
languageName: node
linkType: hard
"@testing-library/react@npm:14.0.0":
version: 14.0.0
resolution: "@testing-library/react@npm:14.0.0"
@@ -7715,6 +7744,26 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^16.0.1":
version: 16.0.1
resolution: "@testing-library/react@npm:16.0.1"
dependencies:
"@babel/runtime": ^7.12.5
peerDependencies:
"@testing-library/dom": ^10.0.0
"@types/react": ^18.0.0
"@types/react-dom": ^18.0.0
react: ^18.0.0
react-dom: ^18.0.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 1837db473ea018cf2b5d0cbfffb7a30d0d759e5a7f23aad431441c77bcc3d2533250cd003a61878fd908267df47404cedcb5914f12d79e413002c659652b37fd
languageName: node
linkType: hard
"@tootallnate/once@npm:2":
version: 2.0.0
resolution: "@tootallnate/once@npm:2.0.0"
@@ -8097,6 +8146,16 @@ __metadata:
languageName: node
linkType: hard
"@types/jest@npm:^29.5.13":
version: 29.5.13
resolution: "@types/jest@npm:29.5.13"
dependencies:
expect: ^29.0.0
pretty-format: ^29.0.0
checksum: 875ac23c2398cdcf22aa56c6ba24560f11d2afda226d4fa23936322dde6202f9fdbd2b91602af51c27ecba223d9fc3c1e33c9df7e47b3bf0e2aefc6baf13ce53
languageName: node
linkType: hard
"@types/jsdom@npm:^20.0.0":
version: 20.0.1
resolution: "@types/jsdom@npm:20.0.1"
@@ -10180,6 +10239,13 @@ __metadata:
languageName: node
linkType: hard
"aria-query@npm:^5.0.0":
version: 5.3.2
resolution: "aria-query@npm:5.3.2"
checksum: d971175c85c10df0f6d14adfe6f1292409196114ab3c62f238e208b53103686f46cc70695a4f775b73bc65f6a09b6a092fd963c4f3a5a7d690c8fc5094925717
languageName: node
linkType: hard
"aria-query@npm:^5.1.3":
version: 5.3.0
resolution: "aria-query@npm:5.3.0"
@@ -11054,7 +11120,7 @@ __metadata:
languageName: node
linkType: hard
"bs-logger@npm:0.x":
"bs-logger@npm:0.x, bs-logger@npm:^0.2.6":
version: 0.2.6
resolution: "bs-logger@npm:0.2.6"
dependencies:
@@ -11342,6 +11408,16 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^3.0.0":
version: 3.0.0
resolution: "chalk@npm:3.0.0"
dependencies:
ansi-styles: ^4.1.0
supports-color: ^7.1.0
checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505
languageName: node
linkType: hard
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -12422,6 +12498,13 @@ __metadata:
languageName: node
linkType: hard
"css.escape@npm:^1.5.1":
version: 1.5.1
resolution: "css.escape@npm:1.5.1"
checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774
languageName: node
linkType: hard
"cssesc@npm:^3.0.0":
version: 3.0.0
resolution: "cssesc@npm:3.0.0"
@@ -13241,6 +13324,13 @@ __metadata:
languageName: node
linkType: hard
"dom-accessibility-api@npm:^0.6.3":
version: 0.6.3
resolution: "dom-accessibility-api@npm:0.6.3"
checksum: c325b5144bb406df23f4affecffc117dbaec9af03daad9ee6b510c5be647b14d28ef0a4ea5ca06d696d8ab40bb777e5fed98b985976fdef9d8790178fa1d573f
languageName: node
linkType: hard
"dom-converter@npm:^0.2.0":
version: 0.2.0
resolution: "dom-converter@npm:0.2.0"
@@ -16625,7 +16715,7 @@ __metadata:
languageName: node
linkType: hard
"ipaddr.js@npm:^2.0.1":
"ipaddr.js@npm:^2.0.1, ipaddr.js@npm:^2.2.0":
version: 2.2.0
resolution: "ipaddr.js@npm:2.2.0"
checksum: 770ba8451fd9bf78015e8edac0d5abd7a708cbf75f9429ca9147a9d2f3a2d60767cd5de2aab2b1e13ca6e4445bdeff42bf12ef6f151c07a5c6cf8a44328e2859
@@ -17893,7 +17983,7 @@ __metadata:
languageName: node
linkType: hard
"jest@npm:^29.4.1":
"jest@npm:^29.4.1, jest@npm:^29.7.0":
version: 29.7.0
resolution: "jest@npm:29.7.0"
dependencies:
@@ -18781,7 +18871,7 @@ __metadata:
languageName: node
linkType: hard
"make-error@npm:1.x, make-error@npm:^1.1.1":
"make-error@npm:1.x, make-error@npm:^1.1.1, make-error@npm:^1.3.6":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
checksum: b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402
@@ -23421,7 +23511,7 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0":
"semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3":
version: 7.6.3
resolution: "semver@npm:7.6.3"
bin:
@@ -24946,6 +25036,43 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.2.5":
version: 29.2.5
resolution: "ts-jest@npm:29.2.5"
dependencies:
bs-logger: ^0.2.6
ejs: ^3.1.10
fast-json-stable-stringify: ^2.1.0
jest-util: ^29.0.0
json5: ^2.2.3
lodash.memoize: ^4.1.2
make-error: ^1.3.6
semver: ^7.6.3
yargs-parser: ^21.1.1
peerDependencies:
"@babel/core": ">=7.0.0-beta.0 <8"
"@jest/transform": ^29.0.0
"@jest/types": ^29.0.0
babel-jest: ^29.0.0
jest: ^29.0.0
typescript: ">=4.3 <6"
peerDependenciesMeta:
"@babel/core":
optional: true
"@jest/transform":
optional: true
"@jest/types":
optional: true
babel-jest:
optional: true
esbuild:
optional: true
bin:
ts-jest: cli.js
checksum: d60d1e1d80936f6002b1bb27f7e062408bc733141b9d666565503f023c340a3196d506c836a4316c5793af81a5f910ab49bb9c13f66e2dc66de4e0f03851dbca
languageName: node
linkType: hard
"ts-node@npm:^10.9.1":
version: 10.9.2
resolution: "ts-node@npm:10.9.2"