fix: enforce gallery access and consolidate gallery workflows (#1357)

Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
This commit is contained in:
Paul Nothaft
2026-09-08 15:34:09 +02:00
committed by GitHub
parent 895e5ab3cc
commit f0e6d2dfb1
120 changed files with 7147 additions and 8525 deletions
+59 -48
View File
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
});
// CORS configuration (apply only to API routes)
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
const corsOptions = {
origin: function (origin, callback) {
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
// multipart is exactly what a cross-site <form> can send without a
// preflight, and in a split-origin deployment (SameSite=None) the admin
// cookie rides along to the upload routes. Browsers label such a
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
// cross-origin POST); non-browser clients send neither header and pass.
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
}
}
next();
});
// Validate the origin independently of body length/content type.
app.use('/api', require('./src/middleware/csrf'));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
app.use('/api', require('./src/middleware/apiRequestLogger'));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -1098,6 +1065,30 @@ if (spaCatchAll) {
// Global error handler (must be last)
app.use(errorHandler);
// App construction is side-effect free with respect to listening and workers.
let httpServer;
let shutdownPromise;
// Docker stops a container 10 s after SIGTERM by default (compose sets no
// stop_grace_period), so the drain must finish inside that window.
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 8000;
async function stopServer() {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
const close = httpServer ? new Promise((resolve, reject) => httpServer.close(err => err ? reject(err) : resolve())) : Promise.resolve();
const timeout = setTimeout(() => httpServer?.closeAllConnections(), Math.floor(SHUTDOWN_TIMEOUT_MS / 2));
timeout.unref();
try {
await Promise.all([close, require('./src/services/serviceShutdown').stopServices()]);
} finally {
clearTimeout(timeout);
// Always release the pool: a rejected service stop must not leave
// ref'd sockets keeping the process alive until SIGKILL.
await db.destroy();
}
})();
return shutdownPromise;
}
// Initialize services
async function startServer() {
try {
@@ -1122,14 +1113,8 @@ async function startServer() {
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
// Initialize temp upload cleanup job
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
// Run cleanup on startup
cleanupTempUploads();
// Schedule periodic cleanup every hour
setInterval(cleanupTempUploads, 60 * 60 * 1000);
logger.info('Temp upload cleanup scheduled');
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
// Start file watcher
startFileWatcher();
// External-media folder watcher (issue 1187): imports new files into
@@ -1149,6 +1134,10 @@ async function startServer() {
startTransferCleanup();
// Custom-resolution download archives (#858) are disposable renditions —
// sweep them once their TTL passes so .download-cache doesn't grow forever.
// Best-effort, as before the scheduler refactor: a transient DB error on
// this one UPDATE must not abort the whole server start.
await require('./src/services/downloadJobService').recoverOrphanedJobs()
.catch((err) => logger.error('Download job recovery failed', { error: err.message }));
startDownloadJobCleanup();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
@@ -1306,7 +1295,7 @@ async function startServer() {
// lazy means they don't pay for a module graph they never use.
require('./src/services/faceQueue').start();
app.listen(PORT, () => {
httpServer = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
@@ -1325,10 +1314,32 @@ async function startServer() {
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
await stopServer();
process.exitCode = 1;
}
}
startServer();
if (require.main === module) {
let stopping = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (stopping) {
logger.warn(`Received ${signal} again during shutdown, exiting immediately`);
process.exit(1);
}
stopping = true;
// The drain itself has no deadline; a hung worker must not keep the
// process alive past the container's stop grace period.
setTimeout(() => {
logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
stopServer().catch(error => { logger.error('Shutdown failed', { error: error.message }); process.exitCode = 1; });
});
}
startServer();
}
app.startServer = startServer;
app.stopServer = stopServer;
module.exports = app; // For testing