apply security audit
This commit is contained in:
@@ -55,7 +55,7 @@ const environment: EnvironmentConfig = {
|
||||
password: process.env.DB_PASSWORD || 'dev_password',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
secret: process.env.JWT_SECRET || '',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
},
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,10 @@ class BookStackController {
|
||||
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
|
||||
return;
|
||||
}
|
||||
if (query.trim().length > 500) {
|
||||
res.status(400).json({ success: false, message: 'Suchanfrage zu lang' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await bookstackService.searchPages(query.trim());
|
||||
res.status(200).json({ success: true, data: results, configured: true });
|
||||
|
||||
@@ -46,9 +46,7 @@ export const errorHandler = (
|
||||
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message,
|
||||
message: 'An internal error occurred',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export function requirePermission(permission: string) {
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: `Keine Berechtigung: ${permission}`,
|
||||
message: 'Keine Berechtigung',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,14 +110,29 @@ router.post(
|
||||
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.
|
||||
* The controller itself enforces the correct Zod schema (full vs. limited)
|
||||
* based on the caller's role.
|
||||
* Route-level guard rejects all other callers before the controller runs.
|
||||
*/
|
||||
router.patch(
|
||||
'/:userId',
|
||||
// No requirePermission here — controller handles own-profile vs. write-role logic
|
||||
requireOwnerOrWrite,
|
||||
memberController.updateMember.bind(memberController)
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,47 @@ export interface BookStackSearchResult {
|
||||
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> {
|
||||
const { bookstack } = environment;
|
||||
return {
|
||||
@@ -42,8 +83,8 @@ function buildHeaders(): Record<string, string> {
|
||||
|
||||
async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url) {
|
||||
throw new Error('BOOKSTACK_URL is not configured');
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -73,8 +114,8 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
|
||||
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url) {
|
||||
throw new Error('BOOKSTACK_URL is not configured');
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -34,10 +34,51 @@ interface LoginFlowCredentials {
|
||||
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> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured');
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -60,6 +101,9 @@ async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
||||
}
|
||||
|
||||
async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> {
|
||||
if (!isValidServiceUrl(pollEndpoint)) {
|
||||
throw new Error('pollEndpoint is not a valid service URL');
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(pollEndpoint, `token=${pollToken}`, {
|
||||
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> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured');
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,47 @@ export interface VikunjaProject {
|
||||
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> {
|
||||
return {
|
||||
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
||||
@@ -25,8 +66,8 @@ function buildHeaders(): Record<string, string> {
|
||||
|
||||
async function getMyTasks(): Promise<VikunjaTask[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url) {
|
||||
throw new Error('VIKUNJA_URL is not configured');
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -58,8 +99,8 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
|
||||
|
||||
async function getProjects(): Promise<VikunjaProject[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url) {
|
||||
throw new Error('VIKUNJA_URL is not configured');
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -82,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
|
||||
|
||||
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url) {
|
||||
throw new Error('VIKUNJA_URL is not configured');
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user