diff --git a/app/network/page.tsx b/app/network/page.tsx index 434e757..4c812e9 100644 --- a/app/network/page.tsx +++ b/app/network/page.tsx @@ -1,14 +1,58 @@ 'use client'; -import React from 'react'; +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { LightClientSyncIndicator } from '@/src/components/network/LightClientSyncIndicator'; import { NetworkGraph } from '@/src/components/network/NetworkGraph'; +import { NodeList } from '@/src/components/network/NodeList'; +import type { NetworkNode } from '@/src/types/node'; + +const DEMO_NODES: NetworkNode[] = [ + { + id: 'demo-1', + displayName: 'Aurora Validator 🚀', + description: 'High-uptime node in the EU region, operated by Aurora Labs. A & B tested.', + location: 'Frankfurt, DE', + contactEmail: 'ops@aurora.example', + websiteUrl: 'https://aurora.example', + }, + { + id: 'demo-2', + displayName: '日本ノード', + description: 'Tokyo-based validator. 高可用性のノードです。', + location: '東京', + websiteUrl: 'http://jp-node.example', + }, +]; + +function NodeDirectory() { + const params = useSearchParams(); + const injectedName = params.get('name'); + const injectedDescription = params.get('description'); + const injectedWebsite = params.get('website'); + + const nodes: NetworkNode[] = + injectedName !== null || injectedDescription !== null || injectedWebsite !== null + ? [ + { + id: 'injected', + displayName: injectedName ?? 'Injected Node', + description: injectedDescription ?? '', + location: 'unknown', + websiteUrl: injectedWebsite ?? undefined, + }, + ...DEMO_NODES, + ] + : DEMO_NODES; + + return ; +} export default function NetworkStatus() { return (

Network Status

- +

Validator topology

@@ -22,6 +66,16 @@ export default function NetworkStatus() {
+ +
+

Node directory

+

+ Operator-supplied labels and descriptions are sanitized before rendering. +

+ Loading...
}> + + +
); diff --git a/e2e/node-xss-regression.spec.ts b/e2e/node-xss-regression.spec.ts new file mode 100644 index 0000000..522b39c --- /dev/null +++ b/e2e/node-xss-regression.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test' + +// Regression for issue #9: operator-supplied node fields must never execute as +// HTML/JS. We inject malicious values through the URL (standing in for the +// configuration API) and assert nothing executes. +test.describe('Node field XSS regression (#9)', () => { + test('img/onerror payload in displayName does not execute', async ({ page }) => { + const dialogs: string[] = [] + page.on('dialog', async (dialog) => { + dialogs.push(dialog.message()) + await dialog.dismiss() + }) + + const payload = 'AcmeNode' + await page.goto(`/network?name=${encodeURIComponent(payload)}`) + + const card = page.getByTestId('node-card-injected') + await expect(card).toBeVisible() + + // No injected element and no script execution. + await page.waitForTimeout(500) + expect(dialogs).toHaveLength(0) + expect(await card.locator('img').count()).toBe(0) + + // The display name renders as sanitized plain text. + const name = await card.getByTestId('node-display-name').innerText() + expect(name).toContain('AcmeNode') + expect(name).not.toContain('<') + expect(name).not.toContain('onerror') + }) + + test('script payload in description is neutralized', async ({ page }) => { + const dialogs: string[] = [] + page.on('dialog', async (dialog) => { + dialogs.push(dialog.message()) + await dialog.dismiss() + }) + + const payload = '">' + await page.goto(`/network?description=${encodeURIComponent(payload)}`) + + const card = page.getByTestId('node-card-injected') + await expect(card).toBeVisible() + + await page.waitForTimeout(500) + expect(dialogs).toHaveLength(0) + expect(await card.locator('script').count()).toBe(0) + const description = await card.getByTestId('node-description').innerText() + expect(description).not.toContain('<') + }) + + test('sends a Content-Security-Policy with object-src none', async ({ page }) => { + const response = await page.goto('/network') + const csp = response?.headers()['content-security-policy'] ?? '' + expect(csp).toContain("object-src 'none'") + expect(csp).toContain('script-src') + }) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index a7079f7..a20b791 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,15 +3,23 @@ import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; import colorThemePlugin from "./src/eslint-plugin-color-theme.mjs"; +const DANGER_MESSAGE = + "dangerouslySetInnerHTML is banned (stored-XSS risk). Render sanitized plain text with / sanitizeNodeField instead."; + const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, { plugins: { - 'color-theme': colorThemePlugin, + "color-theme": colorThemePlugin, }, rules: { - 'color-theme/no-hardcoded-colors': 'warn', + "color-theme/no-hardcoded-colors": "warn", + "no-restricted-syntax": [ + "error", + { selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", message: DANGER_MESSAGE }, + { selector: "Property[key.name='dangerouslySetInnerHTML']", message: DANGER_MESSAGE }, + ], }, }, // Override default ignores of eslint-config-next. diff --git a/next.config.ts b/next.config.ts index 3359cfa..e13444a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,39 @@ import type { NextConfig } from "next"; +// Content-Security-Policy as defense-in-depth for issue #9. The app is +// statically pre-rendered, so a per-request nonce can't be embedded; inline +// scripts (Next's hydration bootstrap) therefore require 'unsafe-inline'. +// 'unsafe-eval' is added only in development for Turbopack HMR. The directives +// the issue calls out are enforced in production: object-src 'none' blocks +// plugins, and script-src is restricted to 'self' (no remote script origins). +// `upgrade-insecure-requests` is omitted so http://localhost keeps working. +const isProd = process.env.NODE_ENV === "production"; +const scriptSrc = isProd ? "'self' 'unsafe-inline'" : "'self' 'unsafe-inline' 'unsafe-eval'"; + +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + `script-src ${scriptSrc}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self' https: wss: ws:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", +].join("; "); + const nextConfig: NextConfig = { headers: async () => [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, { source: "/sw.js", headers: [ diff --git a/package-lock.json b/package-lock.json index 3304da9..80b681f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^4.4.0", + "isomorphic-dompurify": "^2.36.0", "next": "16.1.6", "qrcode": "^1.5.4", "react": "19.2.3", @@ -30,11 +31,18 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "fast-check": "^3.23.2", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.4" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -48,6 +56,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@axe-core/playwright": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", @@ -301,6 +362,152 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -920,6 +1127,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1665,7 +1889,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2589,6 +2813,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", @@ -3626,6 +3857,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "11.1.4", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", @@ -3968,6 +4208,43 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3982,6 +4259,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4050,7 +4340,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4073,6 +4362,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4165,6 +4460,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4208,6 +4512,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4924,6 +5240,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5441,6 +5780,40 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5807,6 +6180,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5979,6 +6358,19 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz", + "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.1", + "jsdom": "^28.0.0" + }, + "engines": { + "node": ">=20.19.5" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6027,6 +6419,68 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6468,6 +6922,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6542,7 +7002,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6887,6 +7346,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7065,12 +7536,28 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -7200,6 +7687,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -7382,6 +7878,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7911,6 +8419,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -8024,6 +8538,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8037,6 +8569,30 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -8243,6 +8799,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -8556,6 +9121,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8708,6 +9317,21 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index b949926..876ba5c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "verinode-frontend", "version": "0.1.0", "private": true, - "description": "Frontend for VeriNode — decentralized savings circles (ROSCA) protocol on Stellar Soroban", + "description": "Frontend for VeriNode — decentralized savings circles (ROSCA) protocol on Stellar Soroban", "scripts": { "dev": "next dev", "build": "next build", @@ -22,6 +22,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^4.4.0", + "isomorphic-dompurify": "^2.36.0", "next": "16.1.6", "qrcode": "^1.5.4", "react": "19.2.3", @@ -40,6 +41,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "fast-check": "^3.23.2", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.4" diff --git a/src/components/network/NodeCard.tsx b/src/components/network/NodeCard.tsx new file mode 100644 index 0000000..4496247 --- /dev/null +++ b/src/components/network/NodeCard.tsx @@ -0,0 +1,55 @@ +import { SafeText } from '@/src/components/shared/SafeText' +import { safeUrl } from '@/src/utils/sanitize' +import type { NetworkNode } from '@/src/types/node' + +/** + * Compact node card. Every operator-supplied string is rendered through + * (sanitized plain text); the website is rendered as a link only if + * it resolves to a safe http(s) URL. + */ +export function NodeCard({ node }: { node: NetworkNode }) { + const website = node.websiteUrl ? safeUrl(node.websiteUrl) : null + + return ( +
+
+

+ +

+ {node.location && ( + + + + )} +
+ +

+ +

+ +
+ {node.contactEmail && ( + + + + )} + {website ? ( + + {website} + + ) : node.websiteUrl ? ( + + ) : null} +
+
+ ) +} diff --git a/src/components/network/NodeDetailPanel.tsx b/src/components/network/NodeDetailPanel.tsx new file mode 100644 index 0000000..8f412d4 --- /dev/null +++ b/src/components/network/NodeDetailPanel.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { SafeText } from '@/src/components/shared/SafeText' +import { safeUrl } from '@/src/utils/sanitize' +import type { NetworkNode } from '@/src/types/node' + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +/** + * Full node detail view. As with NodeCard, every operator-supplied field is + * rendered through ; the website is linked only when it is a safe + * http(s) URL. + */ +export function NodeDetailPanel({ node }: { node: NetworkNode }) { + const website = node.websiteUrl ? safeUrl(node.websiteUrl) : null + + return ( +
+

+ +

+ +
+ + + + + + + {node.contactEmail && ( + + + + )} + + {website ? ( + + {website} + + ) : node.websiteUrl ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/src/components/network/NodeList.tsx b/src/components/network/NodeList.tsx new file mode 100644 index 0000000..c792b93 --- /dev/null +++ b/src/components/network/NodeList.tsx @@ -0,0 +1,17 @@ +import { NodeCard } from '@/src/components/network/NodeCard' +import type { NetworkNode } from '@/src/types/node' + +/** Renders a responsive grid of node cards. */ +export function NodeList({ nodes }: { nodes: NetworkNode[] }) { + if (nodes.length === 0) { + return

No nodes to display.

+ } + + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ) +} diff --git a/src/components/shared/SafeText.tsx b/src/components/shared/SafeText.tsx new file mode 100644 index 0000000..97e7212 --- /dev/null +++ b/src/components/shared/SafeText.tsx @@ -0,0 +1,30 @@ +import type { SanitizableNodeField } from '@/src/types/node' +import { sanitizeNodeField, sanitizeText } from '@/src/utils/sanitize' + +interface SafeTextProps { + text: string | null | undefined + /** Truncate the sanitized output to this many characters (adds an ellipsis). */ + maxLength?: number + /** Apply per-field sanitization (and its max length) when set. */ + field?: SanitizableNodeField + className?: string +} + +/** + * Renders untrusted operator text safely: it sanitizes the value (HTML stripped, + * control chars removed, XSS vectors neutralized) and renders the result as + * plain text inside a . When truncated, the full sanitized value is + * exposed via the `title` attribute. Never uses dangerouslySetInnerHTML. + */ +export function SafeText({ text, maxLength, field, className }: SafeTextProps) { + const clean = field ? sanitizeNodeField(text ?? '', field) : sanitizeText(text ?? '') + + const isTruncated = typeof maxLength === 'number' && clean.length > maxLength + const display = isTruncated ? `${clean.slice(0, maxLength).trimEnd()}…` : clean + + return ( + + {display} + + ) +} diff --git a/src/types/node.ts b/src/types/node.ts new file mode 100644 index 0000000..0fcf095 --- /dev/null +++ b/src/types/node.ts @@ -0,0 +1,18 @@ +// Operator-configurable node fields. All string fields are untrusted input and +// MUST be passed through sanitizeNodeField / before rendering. + +export interface NetworkNode { + id: string + displayName: string + description: string + location: string + contactEmail?: string + websiteUrl?: string +} + +export type SanitizableNodeField = + | 'displayName' + | 'description' + | 'location' + | 'contactEmail' + | 'websiteUrl' diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..a27a7dd --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,105 @@ +// XSS sanitization for operator-configurable node identifier strings. +// +// Node displayName / description / location / contactEmail / websiteUrl are +// arbitrary operator input rendered in every other operator's dashboard, so +// they are prime stored-XSS vectors. Every such value must be passed through +// sanitizeNodeField (or rendered via ) before display. +// +// React already escapes text it renders, so the *primary* defense is "never +// use dangerouslySetInnerHTML" (enforced by ESLint). This module is the second +// layer: it reduces input to plain text with no markup, control characters, or +// protocol-based vectors, while preserving legitimate Unicode (emoji, CJK, +// RTL) and common punctuation. + +import DOMPurify from 'isomorphic-dompurify' +import type { SanitizableNodeField } from '@/src/types/node' + +export const FIELD_MAX_LENGTHS: Record = { + displayName: 50, + description: 500, + location: 120, + contactEmail: 254, + websiteUrl: 2048, +} + +// C0 controls except TAB (U+0009) and LF (U+000A), plus DEL and the C1 block +// (U+007F-U+009F). Printable Unicode — letters, numbers, punctuation, symbols, +// emoji, CJK, RTL — is preserved. Built from an ASCII source string so no +// literal control bytes live in this file. +const CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F]', 'g') + +// DOMPurify entity-encodes text output (e.g. `&` -> `&`); decode the common +// entities back to plain characters for display. +const BASIC_ENTITIES: Array<[RegExp, string]> = [ + [/</gi, '<'], + [/>/gi, '>'], + [/"/gi, '"'], + [/�*39;/g, "'"], + [/�*27;/gi, "'"], + [/ /gi, ' '], + [/&/gi, '&'], // last, so a decoded `&` is not re-interpreted +] + +function decodeBasicEntities(input: string): string { + let out = input + for (const [pattern, replacement] of BASIC_ENTITIES) out = out.replace(pattern, replacement) + return out +} + +// Defense-in-depth: after markup removal, neutralize any residual angle +// brackets and protocol / event-handler tokens so the output can never carry an +// executable vector even if rendered in an unexpected context. Applied to a +// fixpoint because removing one token can splice two halves into a fresh one +// (e.g. "javasjavascript:cript:" -> "javascript:"). +function neutralizeVectors(input: string): string { + let previous: string + let out = input + do { + previous = out + out = out + .replace(/[<>]/g, '') + .replace(/javascript:/gi, '') + .replace(/vbscript:/gi, '') + .replace(/data:\s*text\/html/gi, '') + .replace(/\bon\w+\s*=/gi, '') + } while (out !== previous) + return out +} + +/** + * Reduce arbitrary input to safe plain text: + * 1. strip all HTML tags/attributes via DOMPurify (ALLOWED_TAGS/ATTR: []) + * 2. decode the basic entities DOMPurify re-encodes + * 3. normalize to Unicode NFC + * 4. remove control characters (keeping TAB and LF) + * 5. neutralize residual XSS vectors + * 6. collapse to a trimmed string + */ +export function sanitizeText(input: string): string { + if (typeof input !== 'string' || input.length === 0) return '' + const stripped = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) + const decoded = decodeBasicEntities(stripped).normalize('NFC').replace(CONTROL_CHARS, '') + return neutralizeVectors(decoded).trim() +} + +/** Sanitize and enforce the per-field maximum length. */ +export function sanitizeNodeField(input: string, field: SanitizableNodeField): string { + return sanitizeText(input).slice(0, FIELD_MAX_LENGTHS[field]) +} + +/** + * Return a safe, displayable URL for `websiteUrl`, or null. Only http(s) URLs + * survive — `javascript:`, `data:`, and malformed values are rejected, so the + * result is safe to use as an href. + */ +export function safeUrl(input: string): string | null { + const cleaned = sanitizeText(input) + if (cleaned.length === 0) return null + try { + const url = new URL(cleaned) + if (url.protocol === 'http:' || url.protocol === 'https:') return url.toString() + return null + } catch { + return null + } +} diff --git a/src/utils/tests/sanitize.test.ts b/src/utils/tests/sanitize.test.ts new file mode 100644 index 0000000..830db3a --- /dev/null +++ b/src/utils/tests/sanitize.test.ts @@ -0,0 +1,87 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { FIELD_MAX_LENGTHS, safeUrl, sanitizeNodeField, sanitizeText } from '../sanitize' + +const PAYLOADS = [ + '', + '', + '', + '', + '', + '">', + 'javascript:alert(1)', + 'vbscript:msgbox(1)', + 'data:text/html,', + 'click', + 'onerror=alert(1)', + 'java script:alert(1)', +] + +// Interleave malicious payloads with arbitrary (incl. full-Unicode) text. +const maliciousArb = fc + .array(fc.oneof(fc.constantFrom(...PAYLOADS), fc.string(), fc.fullUnicodeString()), { + minLength: 1, + maxLength: 6, + }) + .map((parts) => parts.join(' ')) + +describe('sanitizeNodeField — XSS property tests (#9)', () => { + it('output never contains an executable vector (500 random malicious strings)', () => { + fc.assert( + fc.property(maliciousArb, (input) => { + for (const field of ['displayName', 'description', 'websiteUrl'] as const) { + const out = sanitizeNodeField(input, field) + expect(out).not.toMatch(/[<>]/) + expect(out).not.toMatch(/javascript:/i) + expect(out).not.toMatch(/vbscript:/i) + expect(out).not.toMatch(/data:\s*text\/html/i) + expect(out).not.toMatch(/\bon\w+\s*=/i) + } + }), + { numRuns: 500 }, + ) + }) + + it('never exceeds the per-field maximum length', () => { + fc.assert( + fc.property(fc.string({ maxLength: 5000 }), (input) => { + expect(sanitizeNodeField(input, 'displayName').length).toBeLessThanOrEqual( + FIELD_MAX_LENGTHS.displayName, + ) + expect(sanitizeNodeField(input, 'description').length).toBeLessThanOrEqual( + FIELD_MAX_LENGTHS.description, + ) + }), + { numRuns: 200 }, + ) + }) +}) + +describe('sanitizeText — known vectors and legitimate input', () => { + it('strips classic XSS vectors to safe text', () => { + expect(sanitizeText('')).toBe('') + expect(sanitizeText('hello')).toBe('hello') + expect(sanitizeText('">')).not.toMatch(/[<>]/) + }) + + it('preserves legitimate Unicode (emoji, CJK, RTL) and punctuation', () => { + expect(sanitizeText('Validator 日本語 🚀 مرحبا')).toBe('Validator 日本語 🚀 مرحبا') + expect(sanitizeText('Node #1 — A & B (eu-west)')).toBe('Node #1 — A & B (eu-west)') + }) + + it('removes control characters but keeps tab and newline', () => { + const withControls = `a${String.fromCharCode(0)}b${String.fromCharCode(7)}c${String.fromCharCode(127)}` + expect(sanitizeText(withControls)).toBe('abc') + expect(sanitizeText('line1\nline2\tend')).toBe('line1\nline2\tend') + }) +}) + +describe('safeUrl', () => { + it('accepts http(s) and rejects dangerous protocols', () => { + expect(safeUrl('https://example.com')).toBe('https://example.com/') + expect(safeUrl('http://node.io/path')).toBe('http://node.io/path') + expect(safeUrl('javascript:alert(1)')).toBeNull() + expect(safeUrl('data:text/html,')).toBeNull() + expect(safeUrl('not a url')).toBeNull() + }) +})