From d5b40500f3f74469aab92929e22acbd0dba8a2f2 Mon Sep 17 00:00:00 2001 From: Jordan Frankfurt Date: Fri, 4 Oct 2024 14:46:32 -0500 Subject: [PATCH] check for frame ip safety (#1036) * check for ip safety * add jest * add gh workflow * middleware->frame calls with dns lookup --- .github/workflows/main.yml | 18 +++ apps/web/app/frames/route.tsx | 49 +++++++- apps/web/jest.config.js | 17 +++ apps/web/jest.setup.js | 1 + apps/web/package.json | 7 ++ apps/web/src/middleware/ipSafe.spec.ts | 148 +++++++++++++++++++++++++ apps/web/src/middleware/ipSafe.ts | 58 ++++++++++ apps/web/tsconfig.json | 3 +- package.json | 2 + yarn.lock | 137 ++++++++++++++++++++++- 10 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 apps/web/jest.config.js create mode 100644 apps/web/jest.setup.js create mode 100644 apps/web/src/middleware/ipSafe.spec.ts create mode 100644 apps/web/src/middleware/ipSafe.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..1170ab2 --- /dev/null +++ b/.github/workflows/main.yml @@ -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 diff --git a/apps/web/app/frames/route.tsx b/apps/web/app/frames/route.tsx index 9bd621b..6e5a8aa 100644 --- a/apps/web/app/frames/route.tsx +++ b/apps/web/app/frames/route.tsx @@ -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) { + 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); diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js new file mode 100644 index 0000000..38b9d64 --- /dev/null +++ b/apps/web/jest.config.js @@ -0,0 +1,17 @@ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const customJestConfig = { + testEnvironment: 'jest-environment-jsdom', + setupFilesAfterEnv: ['/jest.setup.js'], + moduleDirectories: ['node_modules', ''], + moduleNameMapper: { + '^@/components/(.*)$': '/components/$1', + '^@/pages/(.*)$': '/pages/$1', + }, +}; + +module.exports = createJestConfig(customJestConfig); diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js new file mode 100644 index 0000000..0932648 --- /dev/null +++ b/apps/web/jest.setup.js @@ -0,0 +1 @@ +require('@testing-library/jest-dom'); diff --git a/apps/web/package.json b/apps/web/package.json index 1ac7a07..3b627bc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/src/middleware/ipSafe.spec.ts b/apps/web/src/middleware/ipSafe.spec.ts new file mode 100644 index 0000000..9fd7a8a --- /dev/null +++ b/apps/web/src/middleware/ipSafe.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/middleware/ipSafe.ts b/apps/web/src/middleware/ipSafe.ts new file mode 100644 index 0000000..1e09bf5 --- /dev/null +++ b/apps/web/src/middleware/ipSafe.ts @@ -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; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 557d3c0..097ca15 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -18,7 +18,8 @@ "name": "next" } ], - "strictNullChecks": true + "strictNullChecks": true, + "types": ["jest", "@testing-library/jest-dom"] }, "include": [ "global.d.ts", diff --git a/package.json b/package.json index cac9a66..cd40e72 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 479e481..7b66818 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"