Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions app/network/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <NodeList nodes={nodes} />;
}

export default function NetworkStatus() {
return (
<div className="p-8 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8 text-zinc-900 dark:text-zinc-50">Network Status</h1>

<div className="grid grid-cols-1 gap-8">
<div className="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-zinc-200 dark:border-zinc-800">
<h2 className="mb-4 text-xl font-semibold text-zinc-900 dark:text-zinc-50">Validator topology</h2>
Expand All @@ -22,6 +66,16 @@ export default function NetworkStatus() {

<LightClientSyncIndicator />
</div>

<section className="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-zinc-200 dark:border-zinc-800">
<h2 className="mb-2 text-xl font-semibold text-zinc-900 dark:text-zinc-50">Node directory</h2>
<p className="mb-6 text-sm text-zinc-500">
Operator-supplied labels and descriptions are sanitized before rendering.
</p>
<Suspense fallback={<div className="p-8 text-zinc-500">Loading...</div>}>
<NodeDirectory />
</Suspense>
</section>
</div>
</div>
);
Expand Down
58 changes: 58 additions & 0 deletions e2e/node-xss-regression.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = 'Acme<img src=x onerror=alert(1)>Node'
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 = '"><script>alert(document.cookie)</script>'
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')
})
})
12 changes: 10 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SafeText> / 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.
Expand Down
32 changes: 32 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down
Loading
Loading