Skip to content

Commit 60057d4

Browse files
authored
Merge pull request #557 from netzbegruenung/test-branch
fix(security,web,api): harden inputs, fix types, and minor cleanups
2 parents 5c3dbcd + 607ff9c commit 60057d4

17 files changed

Lines changed: 120 additions & 45 deletions

File tree

apps/api/agents/langgraph/PromptProcessor.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,16 @@ export function loadPromptConfig(type: string): PromptConfig {
199199
return configCache.get(type)!;
200200
}
201201

202+
if (!/^[a-z0-9_-]+$/i.test(type)) {
203+
throw new Error(`Invalid prompt config type: ${type}`);
204+
}
205+
202206
try {
203-
const configPath = path.join(__dirname, '../../prompts', `${type}.json`);
207+
const promptsDir = path.resolve(__dirname, '../../prompts');
208+
const configPath = path.resolve(promptsDir, `${type}.json`);
209+
if (!configPath.startsWith(promptsDir + path.sep)) {
210+
throw new Error(`Invalid prompt config type: ${type}`);
211+
}
204212
const configData = fs.readFileSync(configPath, 'utf8');
205213
const config = JSON.parse(configData) as PromptConfig;
206214
configCache.set(type, config);

apps/api/routes.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export async function setupRoutes(app: Application): Promise<void> {
348348
app.use('/api/claude_wahlprogramm', aiGenerationLimiter, wahlprogrammRouter);
349349
app.use('/api/claude_universal', aiGenerationLimiter, universalRouter);
350350
app.use('/api/texte/smart', aiGenerationLimiter, smartTexteRouter);
351-
app.use('/api/texte/playground', requireAuth, playgroundRouter);
351+
app.use('/api/texte/playground', requireAuth, aiGenerationLimiter, playgroundRouter);
352352
app.use('/api/generate-content-title', aiGenerationLimiter, contentTitleRouter);
353353
app.use('/api/claude_gruene_jugend', aiGenerationLimiter, claudeGrueneJugendRoute);
354354
app.use('/api/claude_gruenerator_ask', aiGenerationLimiter, claudeGrueneratorAskRoute);
@@ -365,14 +365,14 @@ export async function setupRoutes(app: Application): Promise<void> {
365365
app.use('/api/share', shareRouter);
366366
app.use('/api/transfer', standardMutationLimiter, transferRouter);
367367
app.use('/api/mem0', requireAuth, mem0Router);
368-
app.use('/api/email', requireAuth, emailRouter);
369-
app.use('/api/auth/init', authInitRouter);
370-
app.use('/api/recent-activity', recentActivityRouter);
368+
app.use('/api/email', requireAuth, standardMutationLimiter, emailRouter);
369+
app.use('/api/auth/init', publicReadLimiter, authInitRouter);
370+
app.use('/api/recent-activity', publicReadLimiter, recentActivityRouter);
371371
app.use('/api/notifications', requireAuth, notificationsRouter);
372372
app.use('/api/media', requireAuth, mediaRouter);
373373
app.use('/api/docs/public', publicDocRouter);
374-
app.use('/api/docs', requireAuth, docsRouter);
375-
app.use('/api/presentations', requireAuth, presentationsRouter);
374+
app.use('/api/docs', requireAuth, standardMutationLimiter, docsRouter);
375+
app.use('/api/presentations', requireAuth, standardMutationLimiter, presentationsRouter);
376376
app.use('/api/boards/public', publicBoardRouter);
377377
app.use('/api/boards', requireAuth, boardsRouter);
378378
app.use('/api/users', requireAuth, usersRouter);

apps/api/routes/auth/initController.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,13 @@ async function fetchSavedTexts(userId: string): Promise<any[]> {
8181
);
8282

8383
return (data || []).map((item: any) => {
84-
const plainText = (item.content || '').replace(/<[^>]*>/g, '').trim();
84+
let plainText = item.content || '';
85+
let prev: string;
86+
do {
87+
prev = plainText;
88+
plainText = plainText.replace(/<[^>]*>/g, '');
89+
} while (plainText !== prev);
90+
plainText = plainText.trim();
8591
const wordCount = plainText.split(/\s+/).filter((w: string) => w.length > 0).length;
8692
return {
8793
id: item.document_id,

apps/api/routes/transfer/transferController.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ router.post(
120120
res.status(500).json({ error: 'Upload fehlgeschlagen. Bitte versuche es erneut.' });
121121
} finally {
122122
if (tempPath) {
123-
fs.unlink(tempPath, () => {});
123+
const expectedDir = path.resolve(os.tmpdir(), 'gruenerator-transfer');
124+
const resolvedTemp = path.resolve(tempPath);
125+
if (resolvedTemp.startsWith(expectedDir + path.sep)) {
126+
fs.unlink(tempPath, () => {});
127+
}
124128
}
125129
}
126130
}

apps/api/routes/workplace/recentActivityController.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,13 @@ export async function fetchRecentTexts(
246246

247247
return rows.map((row: any) => {
248248
const rawContent = typeof row.content === 'string' ? row.content : '';
249-
const stripped = rawContent.replace(/<[^>]*>/g, '').slice(0, 500);
249+
let stripped = rawContent;
250+
let prev: string;
251+
do {
252+
prev = stripped;
253+
stripped = stripped.replace(/<[^>]*>/g, '');
254+
} while (stripped !== prev);
255+
stripped = stripped.slice(0, 500);
250256

251257
return {
252258
id: row.id,

apps/api/services/api-clients/nextcloudApiClient.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import * as os from 'os';
2+
import * as path from 'path';
3+
14
import axios, { type AxiosInstance, type AxiosError } from 'axios';
25

36
import { sanitizeFilename } from '../../utils/validation/index.js';
7+
import { validateUrlSync } from '../../utils/validation/urlSecurity.js';
48

59
// Type Definitions
610
export interface ParsedShareLink {
@@ -73,6 +77,11 @@ class NextcloudApiClient {
7377
this.shareToken = this.parsedLink.shareToken;
7478
this.webdavUrl = `${this.baseURL}/public.php/webdav`;
7579

80+
const urlCheck = validateUrlSync(this.baseURL, { allowedProtocols: ['https:'] });
81+
if (!urlCheck.isValid) {
82+
throw new Error(`Invalid Nextcloud share URL: ${urlCheck.error}`);
83+
}
84+
7685
// Create axios instance with basic authentication using share token
7786
this.axiosInstance = axios.create({
7887
timeout: 30000, // 30 seconds timeout
@@ -280,6 +289,12 @@ class NextcloudApiClient {
280289
}
281290
uploadUrl = `${uploadUrl}/${encodeURIComponent(safeFilename)}`;
282291

292+
const expectedDir = path.resolve(os.tmpdir(), 'gruenerator-transfer');
293+
const resolvedPath = path.resolve(filePath);
294+
if (!resolvedPath.startsWith(expectedDir + path.sep) && resolvedPath !== expectedDir) {
295+
throw new Error('File path outside allowed directory');
296+
}
297+
283298
const stat = fs.statSync(filePath);
284299
const stream = fs.createReadStream(filePath);
285300

apps/api/services/monitor/PolitProService.ts

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ interface PolitProResponse {
3232

3333
const PARTY_NAME_MAP: Record<string, string> = {
3434
'CDU/CSU': 'CDU/CSU',
35-
'AfD': 'AfD',
36-
'SPD': 'SPD',
37-
'Grüne': 'GRÜNE',
38-
'Linke': 'DIE LINKE',
39-
'BSW': 'BSW',
40-
'FDP': 'FDP',
35+
AfD: 'AfD',
36+
SPD: 'SPD',
37+
Grüne: 'GRÜNE',
38+
Linke: 'DIE LINKE',
39+
BSW: 'BSW',
40+
FDP: 'FDP',
4141
};
4242

4343
async function fetchPolitPro(parliament: string, year: number): Promise<PolitProResponse | null> {
@@ -110,7 +110,7 @@ export interface PolitProPollData extends PollData {
110110
function toExtendedPollData(
111111
data: PolitProResponse,
112112
parliament: string,
113-
institutePolls?: PollResult[],
113+
institutePolls?: PollResult[]
114114
): PolitProPollData {
115115
const base = toPollData(data, parliament);
116116

@@ -121,7 +121,9 @@ function toExtendedPollData(
121121
}
122122

123123
const useInstitutePolls = institutePolls && institutePolls.length > 0;
124-
log.info(`[toExtendedPollData] Branch: ${useInstitutePolls ? `institutePolls (${institutePolls!.length})` : `base.polls (${base.polls.length})`}`);
124+
log.info(
125+
`[toExtendedPollData] Branch: ${useInstitutePolls ? `institutePolls (${institutePolls!.length})` : `base.polls (${base.polls.length})`}`
126+
);
125127

126128
return {
127129
...base,
@@ -160,7 +162,9 @@ async function scrapeInstitutePolls(parliament: string): Promise<PollResult[]> {
160162
log.info(`[scrapeInstitutePolls] Found ${pollListItems.length} .poll-list-item elements`);
161163

162164
if (pollListItems.length === 0) {
163-
log.warn(`[scrapeInstitutePolls] No .poll-list-item elements found — HTML snippet (first 500 chars): ${html.slice(0, 500)}`);
165+
log.warn(
166+
`[scrapeInstitutePolls] No .poll-list-item elements found — HTML snippet (first 500 chars): ${html.slice(0, 500)}`
167+
);
164168
}
165169

166170
const polls: PollResult[] = [];
@@ -171,23 +175,36 @@ async function scrapeInstitutePolls(parliament: string): Promise<PollResult[]> {
171175
if (!institute) return;
172176

173177
const parties: Record<string, number | null> = {};
174-
$(el).find('.horizontal-parties-list-item').each((_, partyEl) => {
175-
const name = $(partyEl).attr('title');
176-
const val = $(partyEl).find('.list-horizontal-value').text().trim();
177-
if (name && val) {
178-
const partyName = PARTY_NAME_MAP[name] || name;
179-
parties[partyName] = parseFloat(val) || null;
180-
}
181-
});
178+
$(el)
179+
.find('.horizontal-parties-list-item')
180+
.each((_, partyEl) => {
181+
const name = $(partyEl).attr('title');
182+
const val = $(partyEl).find('.list-horizontal-value').text().trim();
183+
if (name && val) {
184+
const partyName = PARTY_NAME_MAP[name] || name;
185+
parties[partyName] = parseFloat(val) || null;
186+
}
187+
});
182188

183189
polls.push({ institute, date, parties });
184190
});
185191

186192
log.info(`[scrapeInstitutePolls] Parsed ${polls.length} institute polls for ${parliament}`);
187193
if (polls.length > 0) {
188-
log.info(`[scrapeInstitutePolls] First 2 polls:`, polls.slice(0, 2).map((p) => ({ institute: p.institute, date: p.date, partyCount: Object.keys(p.parties).length })));
194+
log.info(
195+
`[scrapeInstitutePolls] First 2 polls:`,
196+
polls
197+
.slice(0, 2)
198+
.map((p) => ({
199+
institute: p.institute,
200+
date: p.date,
201+
partyCount: Object.keys(p.parties).length,
202+
}))
203+
);
189204
} else {
190-
log.warn(`[scrapeInstitutePolls] 0 institute polls parsed despite ${pollListItems.length} DOM elements`);
205+
log.warn(
206+
`[scrapeInstitutePolls] 0 institute polls parsed despite ${pollListItems.length} DOM elements`
207+
);
191208
}
192209
return polls;
193210
} catch (error) {
@@ -197,15 +214,22 @@ async function scrapeInstitutePolls(parliament: string): Promise<PollResult[]> {
197214
}
198215

199216
export async function getPolitProPolls(
200-
parliament = 'deutschland',
217+
parliament = 'deutschland'
201218
): Promise<PolitProPollData | null> {
219+
if (!VALID_PARLIAMENT_IDS.has(parliament)) {
220+
log.warn(`[getPolitProPolls] Invalid parliament ID: ${parliament}`);
221+
return null;
222+
}
223+
202224
const cacheKey = `monitor:politpro:${parliament}`;
203225

204226
try {
205227
const cached = await redisClient.get(cacheKey);
206228
if (cached) {
207229
const parsed = JSON.parse(cached) as PolitProPollData;
208-
log.info(`[getPolitProPolls] Cache hit (${parliament}): ${parsed.polls.length} polls, first poll date: ${parsed.polls[0]?.date ?? 'none'}, institute: ${parsed.polls[0]?.institute ?? 'none'}`);
230+
log.info(
231+
`[getPolitProPolls] Cache hit (${parliament}): ${parsed.polls.length} polls, first poll date: ${parsed.polls[0]?.date ?? 'none'}, institute: ${parsed.polls[0]?.institute ?? 'none'}`
232+
);
209233
return parsed;
210234
}
211235
} catch {
@@ -221,13 +245,15 @@ export async function getPolitProPolls(
221245

222246
log.info(`[getPolitProPolls] Institute polls found: ${institutePolls.length} for ${parliament}`);
223247
if (institutePolls.length === 0) {
224-
log.warn(`[getPolitProPolls] No institute polls scraped — will fall back to API interpolated data`);
248+
log.warn(
249+
`[getPolitProPolls] No institute polls scraped — will fall back to API interpolated data`
250+
);
225251
}
226252

227253
const result = toExtendedPollData(data, parliament, institutePolls);
228254

229255
log.info(
230-
`[getPolitProPolls] Final result (${parliament}): ${result.polls.length} polls, ${Object.keys(result.average).length} parties`,
256+
`[getPolitProPolls] Final result (${parliament}): ${result.polls.length} polls, ${Object.keys(result.average).length} parties`
231257
);
232258

233259
try {
@@ -259,3 +285,5 @@ export const POLITPRO_PARLIAMENTS = [
259285
{ id: 'schleswig-holstein', name: 'Schleswig-Holstein' },
260286
{ id: 'thueringen', name: 'Thüringen' },
261287
] as const;
288+
289+
const VALID_PARLIAMENT_IDS: Set<string> = new Set(POLITPRO_PARLIAMENTS.map((p) => p.id));

apps/api/services/scrapers/implementations/UrlCrawler/validators/UrlValidator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import { createRequire } from 'module';
88
import { URL } from 'url';
99

10+
import { safeFetch } from '../../../../../utils/validation/urlSecurity.js';
11+
1012
const require = createRequire(import.meta.url);
1113
const robotsParser = require('robots-parser') as (
1214
url: string,
@@ -35,7 +37,7 @@ async function isAllowedByRobotsTxt(url: string): Promise<boolean> {
3537
}
3638

3739
const robotsUrl = `${origin}/robots.txt`;
38-
const resp = await fetch(robotsUrl, {
40+
const resp = await safeFetch(robotsUrl, {
3941
signal: AbortSignal.timeout(ROBOTS_FETCH_TIMEOUT),
4042
headers: { 'User-Agent': BOT_USER_AGENT },
4143
});

apps/docs/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ app.get('/document/:id', async (req, res, next) => {
127127
const docId = match[1];
128128

129129
try {
130-
const ogRes = await fetch(`${API_TARGET}/api/docs/public/${docId}/og`);
130+
const ogRes = await fetch(`${API_TARGET}/api/docs/public/${encodeURIComponent(docId!)}/og`);
131131
if (!ogRes.ok) {
132132
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
133133
return res.send(indexHtmlTemplate);

apps/web/src/components/common/TemplatePreviewModal/TemplatePreviewModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ interface TemplateMetadata {
4040
}
4141

4242
interface Template {
43-
id?: string;
43+
id?: string | number;
4444
content_data?: TemplateContentData;
4545
metadata?: TemplateMetadata;
4646
external_url?: string;

0 commit comments

Comments
 (0)