apply security audit
This commit is contained in:
@@ -30,8 +30,14 @@ const LoginCallback: React.FC = () => {
|
||||
|
||||
try {
|
||||
await login(code);
|
||||
// Navigate to the originally intended page, falling back to the dashboard
|
||||
const from = sessionStorage.getItem('auth_redirect_from') || '/dashboard';
|
||||
// Navigate to the originally intended page, falling back to the dashboard.
|
||||
// Validate that the stored path is a safe internal path: must start with '/'
|
||||
// but must NOT start with '//' (protocol-relative redirect).
|
||||
const rawFrom = sessionStorage.getItem('auth_redirect_from');
|
||||
const from =
|
||||
rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')
|
||||
? rawFrom
|
||||
: '/dashboard';
|
||||
sessionStorage.removeItem('auth_redirect_from');
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,13 +13,14 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackPage } from '../../types/bookstack.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
|
||||
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||
page,
|
||||
showDivider,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
window.open(page.url, '_blank', 'noopener,noreferrer');
|
||||
safeOpenUrl(page.url);
|
||||
};
|
||||
|
||||
const relativeTime = page.updated_at
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Search, MenuBook } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackSearchResult } from '../../types/bookstack.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
@@ -28,7 +29,7 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
|
||||
onClick={() => safeOpenUrl(result.url)}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { nextcloudApi } from '../../services/nextcloud';
|
||||
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||
@@ -27,7 +28,7 @@ const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivid
|
||||
showDivider,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
window.open(conversation.url, '_blank', 'noopener,noreferrer');
|
||||
safeOpenUrl(conversation.url);
|
||||
};
|
||||
|
||||
const relativeTime = conversation.lastMessage
|
||||
|
||||
@@ -14,6 +14,7 @@ import { format, isPast } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { vikunjaApi } from '../../services/vikunja';
|
||||
import type { VikunjaTask } from '../../types/vikunja.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
|
||||
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
||||
0: { label: 'Keine', color: 'default' },
|
||||
@@ -30,7 +31,7 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
|
||||
vikunjaUrl,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer');
|
||||
safeOpenUrl(`${vikunjaUrl}/tasks/${task.id}`);
|
||||
};
|
||||
|
||||
const dueDateStr = task.due_date
|
||||
|
||||
@@ -25,6 +25,20 @@ import type { Notification, NotificationSchwere } from '../../types/notification
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Only allow window.open for URLs whose origin matches the current app origin.
|
||||
* External-looking URLs (different host or protocol-relative) are rejected to
|
||||
* prevent open-redirect / tab-napping via notification link data from the backend.
|
||||
*/
|
||||
function isTrustedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
|
||||
if (schwere === 'fehler') return 'error';
|
||||
if (schwere === 'warnung') return 'warning';
|
||||
@@ -103,7 +117,11 @@ const NotificationBell: React.FC = () => {
|
||||
handleClose();
|
||||
if (n.link) {
|
||||
if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
|
||||
window.open(n.link, '_blank');
|
||||
if (isTrustedUrl(n.link)) {
|
||||
window.open(n.link, '_blank');
|
||||
} else {
|
||||
console.warn('NotificationBell: blocked navigation to untrusted URL', n.link);
|
||||
}
|
||||
} else {
|
||||
navigate(n.link);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ function Login() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
setIsRedirecting(true);
|
||||
const from = (location.state as any)?.from || '/dashboard';
|
||||
const rawFrom = (location.state as any)?.from;
|
||||
const from =
|
||||
rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')
|
||||
? rawFrom
|
||||
: '/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, location.state]);
|
||||
@@ -31,10 +35,11 @@ function Login() {
|
||||
const handleLogin = () => {
|
||||
try {
|
||||
// Persist the intended destination so LoginCallback can restore it
|
||||
// after the full-page Authentik redirect round-trip
|
||||
const from = (location.state as any)?.from;
|
||||
if (from) {
|
||||
sessionStorage.setItem('auth_redirect_from', from);
|
||||
// after the full-page Authentik redirect round-trip.
|
||||
// Validate that from is a safe internal path before storing it.
|
||||
const rawFrom = (location.state as any)?.from;
|
||||
if (rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')) {
|
||||
sessionStorage.setItem('auth_redirect_from', rawFrom);
|
||||
}
|
||||
const authUrl = authService.getAuthUrl();
|
||||
window.location.href = authUrl;
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
const apiUrl: string = import.meta.env.VITE_API_URL;
|
||||
const authentikUrl: string = import.meta.env.AUTHENTIK_URL || 'https://auth.firesuite.feuerwehr-rems.at';
|
||||
const clientId: string = import.meta.env.AUTHENTIK_CLIENT_ID;
|
||||
|
||||
if (!apiUrl) {
|
||||
console.error('Missing required environment variable: VITE_API_URL');
|
||||
}
|
||||
if (!clientId) {
|
||||
console.error('Missing required environment variable: AUTHENTIK_CLIENT_ID');
|
||||
}
|
||||
|
||||
export const config = {
|
||||
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
authentikUrl: import.meta.env.AUTHENTIK_URL || 'https://auth.firesuite.feuerwehr-rems.at',
|
||||
clientId: import.meta.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here',
|
||||
apiUrl,
|
||||
authentikUrl,
|
||||
clientId,
|
||||
};
|
||||
|
||||
export const API_URL = config.apiUrl;
|
||||
|
||||
20
frontend/src/utils/safeOpenUrl.ts
Normal file
20
frontend/src/utils/safeOpenUrl.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Safely opens a URL in a new tab.
|
||||
*
|
||||
* Validates the URL before opening it to prevent malicious URLs (e.g.
|
||||
* javascript: or data: URIs) from being opened if an API response is
|
||||
* ever compromised. Only http: and https: URLs are allowed.
|
||||
*/
|
||||
export function safeOpenUrl(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
console.warn(`safeOpenUrl: blocked URL with unexpected protocol "${parsed.protocol}": ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn(`safeOpenUrl: blocked invalid URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
@@ -23,6 +23,6 @@ export default defineConfig({
|
||||
envPrefix: ['VITE_', 'AUTHENTIK_'],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user