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
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ We enforce a strict CSP through both Nginx and React-level meta tags.
- **Restrictions**: `'unsafe-inline'` is prohibited in production.
- **Allowed Sources**:
- Scripts/Styles: `'self'`
- API Connections: `https://*.stellar.org`, `https://api.coingecko.com`
- API Connections/Wallets: `https://*.stellar.org`, `wss://*.stellar.org`, `https://*.sorobanrpc.com`, `https://api.coingecko.com`, `wss://*.walletconnect.com`, `https://*.walletconnect.com`, `https://*.walletconnect.org`, `https://albedo.link`, `https://*.albedo.link`
- Inline Scripts: Allowed via strict SHA-256 hash validation for the theme initialization script.

### Adding New Wallet or API Endpoints
To add a new endpoint or wallet integration, update the `Content-Security-Policy` header in `nginx.conf` and the corresponding meta tag in `index.html`. Add the domains to `connect-src` (for APIs/WebSocket) or `frame-src` (for iframes).

### 2. Automated Guardrails
- **Dependabot**: Monitors `npm` and `github-actions` ecosystems daily for updates.
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-2cZtek/4Mta9v4mtLkqsK+tSR0rUJ9LWlEhKKvL5/Ts='; style-src 'self' https://fonts.googleapis.com; connect-src 'self' https://*.stellar.org wss://*.stellar.org https://*.sorobanrpc.com https://api.coingecko.com wss://*.walletconnect.com https://*.walletconnect.com https://*.walletconnect.org; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; frame-src 'self' https://albedo.link https://*.albedo.link; object-src 'none'; base-uri 'self'; upgrade-insecure-requests;" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stellar Developer Dashboard</title>
Expand Down
2 changes: 1 addition & 1 deletion nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://*.stellar.org https://api.coingecko.com; img-src 'self' data: https:; font-src 'self'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-2cZtek/4Mta9v4mtLkqsK+tSR0rUJ9LWlEhKKvL5/Ts='; style-src 'self' https://fonts.googleapis.com; connect-src 'self' https://*.stellar.org wss://*.stellar.org https://*.sorobanrpc.com https://api.coingecko.com wss://*.walletconnect.com https://*.walletconnect.com https://*.walletconnect.org; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; frame-src 'self' https://albedo.link https://*.albedo.link; object-src 'none'; base-uri 'self'; upgrade-insecure-requests;" always;

# Health check endpoint
location /health {
Expand Down
92 changes: 45 additions & 47 deletions src/components/dashboard/Transactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ import { useAddressLabels } from '../../hooks/useAddressLabels'
import { generateTransactionDescription } from '../../lib/aiTransactionDescription'

const VIRTUAL_SCROLL_THRESHOLD = 200
const PAGE_SIZE = 100
const PAGE_SIZE = 50

function normalizeSearch(value) {
return String(value || '').toLowerCase().trim()
Expand Down Expand Up @@ -168,6 +168,38 @@ function flattenOperation(op) {
}
}

function InfiniteScrollSentinel({ onIntersect, hasMore, loading, label = 'items' }: { onIntersect: () => void, hasMore: boolean, loading: boolean, label?: string }) {
const sentinelRef = React.useRef<HTMLDivElement>(null)

React.useEffect(() => {
if (!hasMore || loading) return

const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onIntersect()
}
}, { rootMargin: '200px' })

if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}

return () => observer.disconnect()
}, [onIntersect, hasMore, loading])

return (
<div ref={sentinelRef} style={{ padding: '14px 18px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'center' }}>
{loading ? (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Loading more...</span>
) : hasMore ? (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Scroll for more</span>
) : (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>No more {label}</span>
)}
</div>
)
}

function LoadingRows({ count, height }) {
return (
<div style={{ padding: '12px 18px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
Expand Down Expand Up @@ -596,29 +628,12 @@ export default function Transactions() {
</div>
</div>
))}
<div style={{ padding: '14px 18px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'center' }}>
{txHasMore || txPagingLoading ? (
<button
onClick={handleLoadMoreTransactions}
disabled={txPagingLoading}
style={{
padding: '8px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-bright)',
background: txPagingLoading ? 'var(--bg-elevated)' : 'transparent',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
cursor: txPagingLoading ? 'not-allowed' : 'pointer',
opacity: txPagingLoading ? 0.8 : 1,
}}
>
{txPagingLoading ? 'Loading...' : 'Load More'}
</button>
) : (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>No more transactions</span>
)}
</div>
<InfiniteScrollSentinel
onIntersect={handleLoadMoreTransactions}
hasMore={txHasMore}
loading={txPagingLoading}
label="transactions"
/>
</>
)}
</div>
Expand Down Expand Up @@ -897,29 +912,12 @@ export default function Transactions() {
</div>
</div>
))}
<div style={{ padding: '14px 18px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'center' }}>
{opsHasMore || opsPagingLoading ? (
<button
onClick={handleLoadMoreOperations}
disabled={opsPagingLoading}
style={{
padding: '8px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-bright)',
background: opsPagingLoading ? 'var(--bg-elevated)' : 'transparent',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
cursor: opsPagingLoading ? 'not-allowed' : 'pointer',
opacity: opsPagingLoading ? 0.8 : 1,
}}
>
{opsPagingLoading ? 'Loading...' : 'Load More'}
</button>
) : (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>No more operations</span>
)}
</div>
<InfiniteScrollSentinel
onIntersect={handleLoadMoreOperations}
hasMore={opsHasMore}
loading={opsPagingLoading}
label="operations"
/>
</>
)}
</div>
Expand Down
55 changes: 55 additions & 0 deletions tests/csp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import assert from 'assert';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const nginxConfPath = path.resolve(__dirname, '../nginx.conf');
const indexHtmlPath = path.resolve(__dirname, '../index.html');

console.log('Running CSP tests...');

try {
// Primary flow: nginx.conf should have a valid CSP header
const nginxContent = fs.readFileSync(nginxConfPath, 'utf8');
const cspRegex = /add_header Content-Security-Policy "(.*)" always;/;
const match = nginxContent.match(cspRegex);
assert(match, 'CSP header missing in nginx.conf');
const csp = match[1];

assert(csp.includes("default-src 'self'"), "CSP missing default-src 'self'");
assert(csp.includes("script-src 'self'"), "CSP missing script-src 'self'");
assert(csp.includes("connect-src"), "CSP missing connect-src");
console.log('✅ Primary flow: nginx.conf valid');

// Primary flow: index.html should have a valid CSP meta tag
const htmlContent = fs.readFileSync(indexHtmlPath, 'utf8');
const htmlCspRegex = /<meta http-equiv="Content-Security-Policy" content="(.*)" \/>/;
const htmlMatch = htmlContent.match(htmlCspRegex);
assert(htmlMatch, 'CSP meta tag missing in index.html');
const htmlCsp = htmlMatch[1];

assert(htmlCsp.includes("default-src 'self'"), "HTML CSP missing default-src 'self'");
assert(htmlCsp.includes("script-src 'self'"), "HTML CSP missing script-src 'self'");
assert(htmlCsp.includes("connect-src"), "HTML CSP missing connect-src");
console.log('✅ Primary flow: index.html valid');

// Boundary case: should allow required Stellar endpoints but not wildcard everything
assert(csp.includes("https://*.stellar.org"), "Missing stellar.org");
assert(csp.includes("wss://*.walletconnect.com"), "Missing walletconnect.com");
assert(!csp.match(/connect-src [^;]*\s\*(?:\s|;)/), "connect-src is too permissive with wildcard");
console.log('✅ Boundary case: Required endpoints allowed securely');

// Failure case: should not allow arbitrary domains like http://evil.com
assert(!csp.includes("evil.com"), "CSP should not allow evil.com");
assert(!csp.includes("http://"), "CSP should not allow http://");
console.log('✅ Failure case: Malicious endpoints blocked');

console.log('All tests passed!');
process.exit(0);
} catch (error) {
console.error('Test failed:', error.message);
process.exit(1);
}
Loading