apply security audit
This commit is contained in:
@@ -55,7 +55,7 @@ const environment: EnvironmentConfig = {
|
|||||||
password: process.env.DB_PASSWORD || 'dev_password',
|
password: process.env.DB_PASSWORD || 'dev_password',
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
secret: process.env.JWT_SECRET || '',
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
@@ -83,4 +83,31 @@ const environment: EnvironmentConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function validateEnvironment(env: EnvironmentConfig): void {
|
||||||
|
const secret = env.jwt.secret;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET is not set. ' +
|
||||||
|
'Set a strong, random secret of at least 32 characters before starting the server.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret === 'your-secret-key-change-in-production') {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET is still set to the known weak default value. ' +
|
||||||
|
'Replace it with a strong, random secret of at least 32 characters.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
`FATAL: JWT_SECRET is too short (${secret.length} characters). ` +
|
||||||
|
'A minimum of 32 characters is required.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateEnvironment(environment);
|
||||||
|
|
||||||
export default environment;
|
export default environment;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class BookStackController {
|
|||||||
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
|
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (query.trim().length > 500) {
|
||||||
|
res.status(400).json({ success: false, message: 'Suchanfrage zu lang' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const results = await bookstackService.searchPages(query.trim());
|
const results = await bookstackService.searchPages(query.trim());
|
||||||
res.status(200).json({ success: true, data: results, configured: true });
|
res.status(200).json({ success: true, data: results, configured: true });
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ export const errorHandler = (
|
|||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: process.env.NODE_ENV === 'production'
|
message: 'An internal error occurred',
|
||||||
? 'Internal server error'
|
|
||||||
: err.message,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function requirePermission(permission: string) {
|
|||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Keine Berechtigung: ${permission}`,
|
message: 'Keine Berechtigung',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,14 +110,29 @@ router.post(
|
|||||||
memberController.createMemberProfile.bind(memberController)
|
memberController.createMemberProfile.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline middleware for PATCH /:userId.
|
||||||
|
* Enforces that the caller is either the profile owner OR holds members:write.
|
||||||
|
* This is the route-level IDOR guard; the controller still applies the
|
||||||
|
* correct Zod schema (full vs. limited fields) based on role.
|
||||||
|
*/
|
||||||
|
const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const isOwner = req.user?.id === req.params.userId;
|
||||||
|
if (isOwner) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not the owner — must have members:write permission
|
||||||
|
requirePermission('members:write')(req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /:userId — open to both privileged users AND the profile owner.
|
* PATCH /:userId — open to both privileged users AND the profile owner.
|
||||||
* The controller itself enforces the correct Zod schema (full vs. limited)
|
* Route-level guard rejects all other callers before the controller runs.
|
||||||
* based on the caller's role.
|
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:userId',
|
'/:userId',
|
||||||
// No requirePermission here — controller handles own-profile vs. write-role logic
|
requireOwnerOrWrite,
|
||||||
memberController.updateMember.bind(memberController)
|
memberController.updateMember.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,47 @@ export interface BookStackSearchResult {
|
|||||||
tags: { name: string; value: string; order: number }[];
|
tags: { name: string; value: string; order: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL is safe to use as an outbound service endpoint.
|
||||||
|
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
|
||||||
|
*/
|
||||||
|
function isValidServiceUrl(raw: string): boolean {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
// Reject plain loopback / localhost names
|
||||||
|
if (hostname === 'localhost' || hostname === '::1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject numeric IPv4 private / loopback / link-local ranges
|
||||||
|
const ipv4Parts = hostname.split('.');
|
||||||
|
if (ipv4Parts.length === 4) {
|
||||||
|
const [a, b] = ipv4Parts.map(Number);
|
||||||
|
if (
|
||||||
|
a === 127 || // 127.0.0.0/8 loopback
|
||||||
|
a === 10 || // 10.0.0.0/8 private
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
||||||
|
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
||||||
|
(a === 169 && b === 254) // 169.254.0.0/16 link-local
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function buildHeaders(): Record<string, string> {
|
function buildHeaders(): Record<string, string> {
|
||||||
const { bookstack } = environment;
|
const { bookstack } = environment;
|
||||||
return {
|
return {
|
||||||
@@ -42,8 +83,8 @@ function buildHeaders(): Record<string, string> {
|
|||||||
|
|
||||||
async function getRecentPages(): Promise<BookStackPage[]> {
|
async function getRecentPages(): Promise<BookStackPage[]> {
|
||||||
const { bookstack } = environment;
|
const { bookstack } = environment;
|
||||||
if (!bookstack.url) {
|
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||||
throw new Error('BOOKSTACK_URL is not configured');
|
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,8 +114,8 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
|||||||
|
|
||||||
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||||
const { bookstack } = environment;
|
const { bookstack } = environment;
|
||||||
if (!bookstack.url) {
|
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||||
throw new Error('BOOKSTACK_URL is not configured');
|
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,10 +34,51 @@ interface LoginFlowCredentials {
|
|||||||
appPassword: string;
|
appPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL is safe to use as an outbound service endpoint.
|
||||||
|
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
|
||||||
|
*/
|
||||||
|
function isValidServiceUrl(raw: string): boolean {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
// Reject plain loopback / localhost names
|
||||||
|
if (hostname === 'localhost' || hostname === '::1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject numeric IPv4 private / loopback / link-local ranges
|
||||||
|
const ipv4Parts = hostname.split('.');
|
||||||
|
if (ipv4Parts.length === 4) {
|
||||||
|
const [a, b] = ipv4Parts.map(Number);
|
||||||
|
if (
|
||||||
|
a === 127 || // 127.0.0.0/8 loopback
|
||||||
|
a === 10 || // 10.0.0.0/8 private
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
||||||
|
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
||||||
|
(a === 169 && b === 254) // 169.254.0.0/16 link-local
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
||||||
const baseUrl = environment.nextcloudUrl;
|
const baseUrl = environment.nextcloudUrl;
|
||||||
if (!baseUrl) {
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
throw new Error('NEXTCLOUD_URL is not configured');
|
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +101,9 @@ async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> {
|
async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> {
|
||||||
|
if (!isValidServiceUrl(pollEndpoint)) {
|
||||||
|
throw new Error('pollEndpoint is not a valid service URL');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(pollEndpoint, `token=${pollToken}`, {
|
const response = await axios.post(pollEndpoint, `token=${pollToken}`, {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@@ -85,8 +129,8 @@ async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<L
|
|||||||
|
|
||||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||||
const baseUrl = environment.nextcloudUrl;
|
const baseUrl = environment.nextcloudUrl;
|
||||||
if (!baseUrl) {
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
throw new Error('NEXTCLOUD_URL is not configured');
|
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,47 @@ export interface VikunjaProject {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL is safe to use as an outbound service endpoint.
|
||||||
|
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
|
||||||
|
*/
|
||||||
|
function isValidServiceUrl(raw: string): boolean {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
// Reject plain loopback / localhost names
|
||||||
|
if (hostname === 'localhost' || hostname === '::1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject numeric IPv4 private / loopback / link-local ranges
|
||||||
|
const ipv4Parts = hostname.split('.');
|
||||||
|
if (ipv4Parts.length === 4) {
|
||||||
|
const [a, b] = ipv4Parts.map(Number);
|
||||||
|
if (
|
||||||
|
a === 127 || // 127.0.0.0/8 loopback
|
||||||
|
a === 10 || // 10.0.0.0/8 private
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
||||||
|
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
||||||
|
(a === 169 && b === 254) // 169.254.0.0/16 link-local
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function buildHeaders(): Record<string, string> {
|
function buildHeaders(): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
||||||
@@ -25,8 +66,8 @@ function buildHeaders(): Record<string, string> {
|
|||||||
|
|
||||||
async function getMyTasks(): Promise<VikunjaTask[]> {
|
async function getMyTasks(): Promise<VikunjaTask[]> {
|
||||||
const { vikunja } = environment;
|
const { vikunja } = environment;
|
||||||
if (!vikunja.url) {
|
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||||
throw new Error('VIKUNJA_URL is not configured');
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -58,8 +99,8 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
|
|||||||
|
|
||||||
async function getProjects(): Promise<VikunjaProject[]> {
|
async function getProjects(): Promise<VikunjaProject[]> {
|
||||||
const { vikunja } = environment;
|
const { vikunja } = environment;
|
||||||
if (!vikunja.url) {
|
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||||
throw new Error('VIKUNJA_URL is not configured');
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -82,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
|
|||||||
|
|
||||||
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
||||||
const { vikunja } = environment;
|
const { vikunja } = environment;
|
||||||
if (!vikunja.url) {
|
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||||
throw new Error('VIKUNJA_URL is not configured');
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ const LoginCallback: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(code);
|
await login(code);
|
||||||
// Navigate to the originally intended page, falling back to the dashboard
|
// Navigate to the originally intended page, falling back to the dashboard.
|
||||||
const from = sessionStorage.getItem('auth_redirect_from') || '/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');
|
sessionStorage.removeItem('auth_redirect_from');
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import { formatDistanceToNow } from 'date-fns';
|
|||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import { bookstackApi } from '../../services/bookstack';
|
import { bookstackApi } from '../../services/bookstack';
|
||||||
import type { BookStackPage } from '../../types/bookstack.types';
|
import type { BookStackPage } from '../../types/bookstack.types';
|
||||||
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
|
||||||
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||||
page,
|
page,
|
||||||
showDivider,
|
showDivider,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
window.open(page.url, '_blank', 'noopener,noreferrer');
|
safeOpenUrl(page.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const relativeTime = page.updated_at
|
const relativeTime = page.updated_at
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Search, MenuBook } from '@mui/icons-material';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { bookstackApi } from '../../services/bookstack';
|
import { bookstackApi } from '../../services/bookstack';
|
||||||
import type { BookStackSearchResult } from '../../types/bookstack.types';
|
import type { BookStackSearchResult } from '../../types/bookstack.types';
|
||||||
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
|
||||||
function stripHtml(html: string): string {
|
function stripHtml(html: string): string {
|
||||||
return html.replace(/<[^>]*>/g, '').trim();
|
return html.replace(/<[^>]*>/g, '').trim();
|
||||||
@@ -28,7 +29,7 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
|
onClick={() => safeOpenUrl(result.url)}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
px: 1,
|
px: 1,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { formatDistanceToNow } from 'date-fns';
|
|||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import { nextcloudApi } from '../../services/nextcloud';
|
import { nextcloudApi } from '../../services/nextcloud';
|
||||||
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
||||||
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 2000;
|
||||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||||
@@ -27,7 +28,7 @@ const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivid
|
|||||||
showDivider,
|
showDivider,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
window.open(conversation.url, '_blank', 'noopener,noreferrer');
|
safeOpenUrl(conversation.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const relativeTime = conversation.lastMessage
|
const relativeTime = conversation.lastMessage
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { format, isPast } from 'date-fns';
|
|||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import { vikunjaApi } from '../../services/vikunja';
|
import { vikunjaApi } from '../../services/vikunja';
|
||||||
import type { VikunjaTask } from '../../types/vikunja.types';
|
import type { VikunjaTask } from '../../types/vikunja.types';
|
||||||
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
||||||
0: { label: 'Keine', color: 'default' },
|
0: { label: 'Keine', color: 'default' },
|
||||||
@@ -30,7 +31,7 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
|
|||||||
vikunjaUrl,
|
vikunjaUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer');
|
safeOpenUrl(`${vikunjaUrl}/tasks/${task.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dueDateStr = task.due_date
|
const dueDateStr = task.due_date
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ import type { Notification, NotificationSchwere } from '../../types/notification
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 60_000; // 60 seconds
|
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' {
|
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
|
||||||
if (schwere === 'fehler') return 'error';
|
if (schwere === 'fehler') return 'error';
|
||||||
if (schwere === 'warnung') return 'warning';
|
if (schwere === 'warnung') return 'warning';
|
||||||
@@ -103,7 +117,11 @@ const NotificationBell: React.FC = () => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
if (n.link) {
|
if (n.link) {
|
||||||
if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
|
if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
|
||||||
|
if (isTrustedUrl(n.link)) {
|
||||||
window.open(n.link, '_blank');
|
window.open(n.link, '_blank');
|
||||||
|
} else {
|
||||||
|
console.warn('NotificationBell: blocked navigation to untrusted URL', n.link);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate(n.link);
|
navigate(n.link);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ function Login() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
setIsRedirecting(true);
|
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 });
|
navigate(from, { replace: true });
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate, location.state]);
|
}, [isAuthenticated, navigate, location.state]);
|
||||||
@@ -31,10 +35,11 @@ function Login() {
|
|||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
try {
|
try {
|
||||||
// Persist the intended destination so LoginCallback can restore it
|
// Persist the intended destination so LoginCallback can restore it
|
||||||
// after the full-page Authentik redirect round-trip
|
// after the full-page Authentik redirect round-trip.
|
||||||
const from = (location.state as any)?.from;
|
// Validate that from is a safe internal path before storing it.
|
||||||
if (from) {
|
const rawFrom = (location.state as any)?.from;
|
||||||
sessionStorage.setItem('auth_redirect_from', from);
|
if (rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')) {
|
||||||
|
sessionStorage.setItem('auth_redirect_from', rawFrom);
|
||||||
}
|
}
|
||||||
const authUrl = authService.getAuthUrl();
|
const authUrl = authService.getAuthUrl();
|
||||||
window.location.href = authUrl;
|
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 = {
|
export const config = {
|
||||||
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
apiUrl,
|
||||||
authentikUrl: import.meta.env.AUTHENTIK_URL || 'https://auth.firesuite.feuerwehr-rems.at',
|
authentikUrl,
|
||||||
clientId: import.meta.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here',
|
clientId,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const API_URL = config.apiUrl;
|
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_'],
|
envPrefix: ['VITE_', 'AUTHENTIK_'],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user