Skip to content

Commit f08d509

Browse files
committed
refactor: sanitize request queries, implement callback-based HTML replacement, and enforce cache-control headers for assets
1 parent 8015a2c commit f08d509

1 file changed

Lines changed: 46 additions & 17 deletions

File tree

backend/src/infrastructure/express/server.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ const adminLimiter = rateLimit({
195195
app.use('/api/', apiLimiter);
196196

197197
app.get('/', (req: Request, res: Response) => {
198-
const userParam = req.query.user || req.query.username;
199-
const { theme } = req.query;
198+
const userQuery = typeof req.query.user === 'string' ? req.query.user : (typeof req.query.username === 'string' ? req.query.username : '');
199+
const themeQuery = typeof req.query.theme === 'string' ? req.query.theme : '';
200200
const indexPath = path.join(__dirname, '../../../../public/index.html');
201201

202202
if (!fs.existsSync(indexPath)) {
@@ -215,58 +215,87 @@ app.get('/', (req: Request, res: Response) => {
215215
let targetUsername = 'creativecode';
216216
let targetTheme = 'radical';
217217

218-
if (typeof userParam === 'string' && /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(userParam)) {
219-
targetUsername = userParam;
218+
if (userQuery && /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(userQuery)) {
219+
targetUsername = userQuery;
220220
}
221-
if (typeof theme === 'string' && /^[a-z\d_]{1,50}$/i.test(theme)) {
222-
targetTheme = theme;
221+
if (themeQuery && /^[a-z\d_]{1,50}$/i.test(themeQuery)) {
222+
targetTheme = themeQuery;
223223
}
224224

225-
const safeImageUrl = escapeXml(`${baseUrl}/api/stats?username=${encodeURIComponent(targetUsername)}&theme=${encodeURIComponent(targetTheme)}`);
225+
const encodedUser = encodeURIComponent(targetUsername);
226+
const encodedTheme = encodeURIComponent(targetTheme);
227+
228+
const safeImageUrl = escapeXml(`${baseUrl}/api/stats?username=${encodedUser}&theme=${encodedTheme}`);
226229
const safeTitle = escapeXml(`Tarjetas de estadísticas para @${targetUsername} | GitHub Helpers`);
227230
const safeDescription = escapeXml(`Mira las estadísticas, lenguajes más usados y trofeos de GitHub para @${targetUsername} generados dinámicamente.`);
228231

229-
// Dynamically replace SEO / OpenGraph tags safely
232+
// Dynamically replace SEO / OpenGraph tags safely using replacer functions
230233
html = html
231234
.replace(
232235
/<meta property="og:image" content="[^"]*"\/?>/gi,
233-
`<meta property="og:image" content="${safeImageUrl}" />`
236+
() => `<meta property="og:image" content="${safeImageUrl}" />`
234237
)
235238
.replace(
236239
/<meta property="twitter:image" content="[^"]*"\/?>/gi,
237-
`<meta property="twitter:image" content="${safeImageUrl}" />`
240+
() => `<meta property="twitter:image" content="${safeImageUrl}" />`
238241
)
239242
.replace(
240243
/<meta property="og:title" content="[^"]*"\/?>/gi,
241-
`<meta property="og:title" content="${safeTitle}" />`
244+
() => `<meta property="og:title" content="${safeTitle}" />`
242245
)
243246
.replace(
244247
/<meta property="twitter:title" content="[^"]*"\/?>/gi,
245-
`<meta property="twitter:title" content="${safeTitle}" />`
248+
() => `<meta property="twitter:title" content="${safeTitle}" />`
246249
)
247250
.replace(
248251
/<meta property="og:description" content="[^"]*"\/?>/gi,
249-
`<meta property="og:description" content="${safeDescription}" />`
252+
() => `<meta property="og:description" content="${safeDescription}" />`
250253
)
251254
.replace(
252255
/<meta property="twitter:description" content="[^"]*"\/?>/gi,
253-
`<meta property="twitter:description" content="${safeDescription}" />`
256+
() => `<meta property="twitter:description" content="${safeDescription}" />`
254257
)
255258
.replace(
256259
/<meta name="description" content="[^"]*"\/?>/gi,
257-
`<meta name="description" content="${safeDescription}" />`
260+
() => `<meta name="description" content="${safeDescription}" />`
258261
)
259-
.replace(/<title>[^<]*<\/title>/gi, `<title>${safeTitle}</title>`);
262+
.replace(/<title>[^<]*<\/title>/gi, () => `<title>${safeTitle}</title>`);
260263

261264
res.setHeader('Content-Type', 'text/html');
265+
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
262266
res.status(200).send(html);
263267
});
264268

265269
app.get('/admin/metrics', adminLimiter, (_req: Request, res: Response) => {
270+
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
266271
res.sendFile(path.join(__dirname, '../../../../public/admin/metrics.html'));
267272
});
268273

269-
app.use(express.static(path.join(__dirname, '../../../../public'), { extensions: ['html'] }));
274+
app.use(
275+
express.static(path.join(__dirname, '../../../../public'), {
276+
extensions: ['html'],
277+
setHeaders: (res, filePath) => {
278+
if (filePath.includes('/_astro/')) {
279+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
280+
} else if (filePath.endsWith('.html')) {
281+
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
282+
}
283+
}
284+
})
285+
);
286+
287+
// Explicit 404 handler for missing static assets to prevent default Express HTML error pages from breaking MIME checks in browsers
288+
app.use('/_astro', (_req: Request, res: Response) => {
289+
res.status(404).type('text/plain').send('Asset not found');
290+
});
291+
292+
app.use((req: Request, res: Response, next: () => void) => {
293+
if (/\.(?:css|js|png|jpg|jpeg|gif|svg|ico|txt|xml|woff2?)$/i.test(req.path)) {
294+
res.status(404).type('text/plain').send('File not found');
295+
return;
296+
}
297+
next();
298+
});
270299

271300
function checkMetricsKey(req: Request, res: Response, next: () => void) {
272301
const expectedKey = process.env.METRICS_KEY;

0 commit comments

Comments
 (0)