Skip to content

Commit e89e1a4

Browse files
committed
fix: XSS validation filters shielding dynamic node identifier strings (#9)
- Add isomorphic-dompurify for SSR-compatible DOMPurify sanitization - Create src/utils/sanitizer.ts with sanitizeNodeString (b/i/a allowlist) and detectDangerPatterns monitoring layer - Build NodeCard and NodeList components with sanitized on-chain data rendering for labels, descriptions, location, owner, firmware, etc. - Add NodeConfigSummary to QRProvisionPanel with sanitized config display - Add Content-Security-Policy headers to next.config.ts as defence-in-depth - Add Playwright E2E tests covering script tags, event handlers, JS URIs, encoded entities, and Unicode homoglyph attacks - Create /node-list-demo page wiring NodeCard/NodeList for manual testing Closes #9
1 parent 7e74257 commit e89e1a4

9 files changed

Lines changed: 1702 additions & 27 deletions

File tree

app/node-list-demo/page.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* Node List Demo Page
3+
*
4+
* Demonstrates the NodeCard and NodeList components with XSS-safe
5+
* rendering of on-chain node data. Includes mock nodes with a
6+
* variety of metadata fields.
7+
*/
8+
9+
'use client'
10+
11+
import { useMemo } from 'react'
12+
import { NodeList } from '@/src/components/network/NodeList'
13+
import { NodeCard } from '@/src/components/network/NodeCard'
14+
import type { NodePosition } from '@/src/types/network'
15+
16+
// ---------------------------------------------------------------------------
17+
// Mock node data simulating on-chain Soroban contract queries
18+
// ---------------------------------------------------------------------------
19+
20+
const MOCK_NODES: NodePosition[] = [
21+
{
22+
id: 'node-001-nyc-edge',
23+
x: 120,
24+
y: 80,
25+
label: 'NYC Edge Router',
26+
color: '#0f766e',
27+
metadata: {
28+
description: 'Primary edge router for NYC metro area',
29+
location: 'New York, US',
30+
ownerName: 'Alice Johnson',
31+
firmwareVersion: 'v2.4.1',
32+
hardwareModel: 'Lumina LR-200',
33+
ipAddress: '10.0.1.1',
34+
uptime: '142d 7h',
35+
},
36+
},
37+
{
38+
id: 'node-002-sfo-edge',
39+
x: 45,
40+
y: 120,
41+
label: '<b>SFO</b> Edge Router',
42+
color: '#0d9488',
43+
metadata: {
44+
description: 'West coast relay node for <i>Pacific</i> traffic',
45+
location: 'San Francisco, US',
46+
ownerName: 'Bob Chen',
47+
firmwareVersion: 'v2.3.8',
48+
hardwareModel: 'Lumina LR-150',
49+
ipAddress: '10.0.2.1',
50+
uptime: '89d 3h',
51+
},
52+
},
53+
{
54+
id: 'node-003-lon-edge',
55+
x: 210,
56+
y: 60,
57+
label: 'London Edge Router',
58+
color: '#0f766e',
59+
metadata: {
60+
description: 'European gateway node',
61+
location: 'London, UK',
62+
ownerName: 'Charlie Patel',
63+
firmwareVersion: 'v2.4.1',
64+
hardwareModel: 'Lumina LR-200',
65+
uptime: '201d 12h',
66+
},
67+
},
68+
{
69+
id: 'node-004-tky-relay',
70+
x: 350,
71+
y: 100,
72+
label: '<b>Tokyo</b> Relay',
73+
color: '#ca8a04',
74+
metadata: {
75+
description: 'Asian-Pacific relay for cross-region traffic',
76+
location: 'Tokyo, JP',
77+
ownerName: 'Diana Tanaka',
78+
firmwareVersion: 'v2.4.0',
79+
hardwareModel: 'Lumina LR-300',
80+
ipAddress: '10.0.4.1',
81+
uptime: '56d 18h',
82+
},
83+
},
84+
{
85+
id: 'node-005-syd-edge',
86+
x: 380,
87+
y: 200,
88+
label: 'Sydney Edge',
89+
color: '#0f766e',
90+
metadata: {
91+
location: 'Sydney, AU',
92+
ownerName: 'Eve Wilson',
93+
hardwareModel: 'Lumina LR-150',
94+
},
95+
},
96+
{
97+
id: 'node-006-fra-core',
98+
x: 200,
99+
y: 45,
100+
label: 'Frankfurt Core',
101+
color: '#7c3aed',
102+
metadata: {
103+
description: '<b>Core</b> routing node for central Europe',
104+
location: 'Frankfurt, DE',
105+
ownerName: 'Frank Müller',
106+
firmwareVersion: 'v2.5.0-beta1',
107+
hardwareModel: 'Lumina LR-500',
108+
ipAddress: '10.0.6.1',
109+
uptime: '320d 1h',
110+
},
111+
},
112+
{
113+
id: 'node-007-minimal',
114+
x: 250,
115+
y: 150,
116+
label: 'Minimal Node',
117+
color: '#94a3b8',
118+
metadata: {},
119+
},
120+
]
121+
122+
export default function NodeListDemoPage() {
123+
const nodes = useMemo(() => MOCK_NODES, [])
124+
125+
return (
126+
<div className="min-h-screen bg-[#f7f4ee] p-8">
127+
<div className="mx-auto max-w-4xl">
128+
{/* Header */}
129+
<div className="mb-8">
130+
<h1 className="text-3xl font-bold text-[#1a1410]">
131+
Node List Demo
132+
</h1>
133+
<p className="mt-2 text-[#6f5f48]">
134+
Demonstrates XSS-safe rendering of on-chain node data via{' '}
135+
<code className="rounded bg-[#ece5d8] px-1 py-0.5 text-xs">
136+
sanitizeNodeString
137+
</code>
138+
. Node labels use DOMPurify with a restrictive allowlist
139+
(<b>b</b>, <i>i</i>, <a>a</a>).
140+
</p>
141+
</div>
142+
143+
{/* Single NodeCard showcase */}
144+
<div className="mb-8">
145+
<h2 className="mb-4 text-lg font-semibold text-[#1a1410]">
146+
Single NodeCard
147+
</h2>
148+
<div className="max-w-md">
149+
<NodeCard
150+
node={nodes[0]}
151+
onClick={(n) => console.log('Clicked:', n.id)}
152+
/>
153+
</div>
154+
</div>
155+
156+
{/* NodeList with search and sort */}
157+
<div className="mb-8">
158+
<h2 className="mb-4 text-lg font-semibold text-[#1a1410]">
159+
NodeList (Searchable)
160+
</h2>
161+
<NodeList
162+
nodes={nodes}
163+
onNodeClick={(n) => console.log('Clicked:', n.id)}
164+
/>
165+
</div>
166+
167+
{/* Security info */}
168+
<div className="rounded-lg border border-[#d8d0c1] bg-white p-6">
169+
<h2 className="mb-4 text-lg font-semibold text-[#1a1410]">
170+
XSS Protection Details
171+
</h2>
172+
<ul className="space-y-2 text-sm text-[#6f5f48]">
173+
<li className="flex items-start gap-2">
174+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
175+
<span>
176+
<strong>DOMPurify</strong> with isomorphic-dompurify for SSR
177+
compatibility
178+
</span>
179+
</li>
180+
<li className="flex items-start gap-2">
181+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
182+
<span>
183+
Only <code className="rounded bg-[#ece5d8] px-1 text-xs">&lt;b&gt;</code>,{' '}
184+
<code className="rounded bg-[#ece5d8] px-1 text-xs">&lt;i&gt;</code>,{' '}
185+
<code className="rounded bg-[#ece5d8] px-1 text-xs">&lt;a&gt;</code>{' '}
186+
tags survive sanitization
187+
</span>
188+
</li>
189+
<li className="flex items-start gap-2">
190+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
191+
<span>
192+
Anchor tags get <code className="rounded bg-[#ece5d8] px-1 text-xs">rel=&quot;nofollow noopener noreferrer&quot;</code>
193+
</span>
194+
</li>
195+
<li className="flex items-start gap-2">
196+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
197+
<span>
198+
Unicode NFC normalization defeats homoglyph attacks
199+
</span>
200+
</li>
201+
<li className="flex items-start gap-2">
202+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
203+
<span>
204+
Danger-pattern detector logs console warnings for monitoring
205+
</span>
206+
</li>
207+
<li className="flex items-start gap-2">
208+
<span className="mt-1 text-[#0f766e]">&#10003;</span>
209+
<span>
210+
Content-Security-Policy headers block inline script execution
211+
</span>
212+
</li>
213+
</ul>
214+
</div>
215+
</div>
216+
</div>
217+
)
218+
}

next.config.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,34 @@ const withSerwist = withSerwistInit({
1818
});
1919

2020
const nextConfig: NextConfig = {
21-
// Reserved for future app-specific options.
21+
// Content-Security-Policy: defence-in-depth against XSS.
22+
// Only allowlisted tags (b, i, a) survive DOMPurify sanitization;
23+
// this header blocks inline script execution as a secondary layer
24+
// should any unsanitized string ever reach the DOM.
25+
async headers() {
26+
return [
27+
{
28+
source: '/((?!_next/static|_next/image|icons|manifest).*)',
29+
headers: [
30+
{
31+
key: 'Content-Security-Policy',
32+
value: [
33+
"default-src 'self'",
34+
"script-src 'self' 'unsafe-inline'",
35+
"style-src 'self' 'unsafe-inline'",
36+
"img-src 'self' data: blob:",
37+
"font-src 'self'",
38+
"connect-src 'self' wss: https:",
39+
"frame-src 'none'",
40+
"object-src 'none'",
41+
"base-uri 'self'",
42+
"form-action 'self'",
43+
].join('; '),
44+
},
45+
],
46+
},
47+
];
48+
},
2249
};
2350

2451
export default withSerwist(nextConfig);

0 commit comments

Comments
 (0)