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
+4 -7
View File
@@ -342,15 +342,12 @@ async function cleanupOldAttempts() {
/**
* Initialize cleanup job
*/
function initializeCleanupJob() {
// Run cleanup every 24 hours
setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000);
// Run initial cleanup
cleanupOldAttempts();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupOldAttempts, { interval: 24 * 60 * 60 * 1000, initialDelay: 0 });
function initializeCleanupJob() { cleanupTask.start(); }
const stopCleanupJob = () => cleanupTask.stop();
module.exports = {
stopCleanupJob,
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
+6 -15
View File
@@ -60,19 +60,10 @@ async function cleanupTempUploads() {
* Start periodic cleanup of temp uploads
* Runs every hour
*/
function startTempUploadCleanup() {
// Run immediately on startup
cleanupTempUploads();
// Then run every hour
setInterval(() => {
cleanupTempUploads();
}, 60 * 60 * 1000); // 1 hour
logger.info('Temp upload cleanup service started');
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupTempUploads, {
interval: 60 * 60 * 1000, initialDelay: 0
});
function startTempUploadCleanup() { cleanupTask.start(); }
function stopTempUploadCleanup() { return cleanupTask.stop(); }
module.exports = {
cleanupTempUploads,
startTempUploadCleanup
};
module.exports = { cleanupTempUploads, startTempUploadCleanup, stopTempUploadCleanup };
+12 -1
View File
@@ -33,4 +33,15 @@ function toIso(value) {
return value;
}
module.exports = { toIso };
// Shared comparison boundary for SQLite epoch values and PostgreSQL Dates.
// Invalid input stays NaN so access-control callers can fail closed.
function toTimestamp(value) {
if (value === null || value === undefined || value === '') return NaN;
try {
return new Date(toIso(value)).getTime();
} catch (_) {
return NaN;
}
}
module.exports = { toIso, toTimestamp };
+38
View File
@@ -0,0 +1,38 @@
const { toTimestamp } = require('./dateNormalize');
const { AppError } = require('./errors');
const logger = require('./logger');
const warnedExpiry = new Set();
const isTrue = (value) => value === true || value === 1 || value === '1';
function isGalleryExpired(event, now = Date.now()) {
if (event.expires_at == null || event.expires_at === '') return false;
const expiry = toTimestamp(event.expires_at);
if (!Number.isFinite(expiry)) {
// Fail closed, but name the row once so an operator can repair it.
if (!warnedExpiry.has(event.id)) {
warnedExpiry.add(event.id);
logger.warn('Unparseable events.expires_at treated as expired', { eventId: event.id, expires_at: String(event.expires_at) });
}
return true;
}
return expiry <= now;
}
function requiresGalleryPassword(event) {
return !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
}
function isGalleryAvailable(event, { adminPreview = false } = {}) {
return !!event && isTrue(event.is_active) && !isTrue(event.is_archived)
&& (adminPreview || (!isTrue(event.is_draft) && !isGalleryExpired(event)));
}
function assertGalleryAvailable(event, { adminPreview = false } = {}) {
if (!isGalleryAvailable(event, { adminPreview })) {
throw new AppError('Gallery not found or expired', 404, 'GALLERY_UNAVAILABLE');
}
}
module.exports = { isGalleryAvailable, assertGalleryAvailable, isGalleryExpired, requiresGalleryPassword };
+23 -20
View File
@@ -190,30 +190,30 @@ function validateExternalUrl(urlString) {
* literal isPrivateIP check alone can't see that. Fails closed on resolution
* failure. IP literals are decided by isPrivateIP without a lookup.
*
* Residual: a determined attacker who controls DNS can still rebind between
* this check and the client's own resolution (TOCTOU). Fully closing that
* needs pinning the connection to the vetted IP, which the underlying
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
* are admin-only, so resolve-and-vet is the proportionate mitigation.
* HTTP clients for admin-configured URLs (webhook delivery, the email webhook
* transport) must use the returned addresses from validateExternalUrlAsync
* with pinnedRequestOptions; a separate preflight alone cannot stop rebinding.
* The analytics tracker proxy, the tracker adapters and OIDC discovery still
* rely on the preflight only.
*
* @param {string} hostname
* @returns {Promise<boolean>} true when safe to connect
*/
async function classifyHost(hostname) {
if (!hostname || typeof hostname !== 'string') return 'invalid';
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
if (isPrivateIP(hostname)) return 'private';
// An IP literal is fully decided above — no name to resolve.
async function resolveHost(hostname) {
if (!hostname || typeof hostname !== 'string') return { reason: 'invalid' };
if (isPrivateIP(hostname)) return { reason: 'private' };
const bare = hostname.replace(/^\[|\]$/g, '');
if (net.isIP(bare)) return 'ok';
if (net.isIP(bare)) return { reason: 'ok', addresses: [{ address: bare, family: net.isIP(bare) }] };
let addresses;
try {
addresses = await dns.lookup(hostname, { all: true });
} catch {
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
}
if (!addresses.length) return 'unresolved';
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
try { addresses = await dns.lookup(hostname, { all: true }); }
catch { return { reason: 'unresolved' }; }
if (!addresses.length) return { reason: 'unresolved' };
if (addresses.some(a => !net.isIP(a.address) || isPrivateIP(a.address))) return { reason: 'private' };
return { reason: 'ok', addresses };
}
async function classifyHost(hostname) {
return (await resolveHost(hostname)).reason;
}
async function isHostAllowed(hostname) {
@@ -237,11 +237,14 @@ async function validateExternalUrlAsync(urlString) {
} catch {
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
}
const reason = await classifyHost(parsed.hostname);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
return { valid: false, error: 'HTTP(S) URL without credentials required', reason: 'invalid' };
}
const { reason, addresses } = await resolveHost(parsed.hostname);
if (reason !== 'ok') {
return { valid: false, error: 'URL points to a private or internal network address', reason };
}
return { valid: true, reason: 'ok' };
return { valid: true, reason: 'ok', hostname: parsed.hostname.replace(/^\[|\]$/g, ''), addresses };
}
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
+28
View File
@@ -0,0 +1,28 @@
/** Axios/Node lookup: connect only to the addresses vetted for this delivery.
* Keep the original URL for Host, TLS SNI and certificate verification.
* Disable environment proxies (which would resolve the destination themselves)
* and redirects. No reusable agent/socket can carry an old DNS decision.
*/
const http = require('http');
const https = require('https');
function pinnedRequestOptions(check) {
if (!check?.valid || !check.hostname || !check.addresses?.length) {
throw new Error('A validated destination is required');
}
const addresses = check.addresses.map(({ address, family }) => ({ address, family }));
const lookup = (hostname, options, callback) => {
if (typeof options === 'function') { callback = options; options = {}; }
if (hostname !== check.hostname) return callback(new Error('Destination hostname changed'));
const family = typeof options === 'number' ? options : options?.family;
const matches = family ? addresses.filter(a => a.family === family) : addresses;
if (!matches.length) return callback(new Error('No validated address for requested family'));
if (options?.all) return callback(null, matches);
callback(null, matches[0].address, matches[0].family);
};
return {
proxy: false, maxRedirects: 0,
httpAgent: new http.Agent({ lookup, keepAlive: false }),
httpsAgent: new https.Agent({ lookup, keepAlive: false }),
};
}
module.exports = { pinnedRequestOptions };
+5 -4
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('./requestLogPath');
/**
* Rate Limiting Security Utilities
* Provides secure rate limiting that prevents bypass attempts
@@ -42,7 +43,7 @@ function hasValidAdminToken(req) {
// Must be admin type to skip rate limiting
if (decoded.type !== 'admin') {
logger.warn('Non-admin token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
tokenType: decoded.type,
ip: req.ip
});
@@ -55,7 +56,7 @@ function hasValidAdminToken(req) {
if (tokenAge > maxAge) {
logger.warn('Old admin token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes',
ip: req.ip
});
@@ -70,7 +71,7 @@ function hasValidAdminToken(req) {
// Log attempts with invalid tokens (potential attacks)
if (error.name === 'JsonWebTokenError') {
logger.warn('Invalid token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
error: error.message,
ip: req.ip
});
@@ -105,7 +106,7 @@ function createSecureSkipFunction() {
function logRateLimitHit(req, res) {
logger.warn('Rate limit exceeded', {
ip: req.ip,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
userAgent: req.headers['user-agent'],
remaining: res.getHeader('X-RateLimit-Remaining'),
limit: res.getHeader('X-RateLimit-Limit')
+12
View File
@@ -0,0 +1,12 @@
/** Log the path without query values or bearer capabilities embedded in it. */
function requestLogPath(value) {
const path = String(value || '/').split(/[?#]/, 1)[0];
return path
.replace(/(\/(?:signed|verify-token|show|download-jobs|invite|accept-invite|password-reset|unsubscribe)\/)[^/]+/gi, '$1[redacted]')
.replace(/(\/api\/public\/[^/]+\/)[^/]+/gi, '$1[redacted]')
.replace(/(\/(?:secure|secure-download)\/[^/]+\/)[^/]+/gi, '$1[redacted]')
.replace(/\b(?:[a-f0-9]{32,}|eyJ[A-Za-z0-9_.-]+)\b/gi, '[redacted]')
// eslint-disable-next-line no-control-regex -- strip log injection control bytes
.replace(/[\r\n\x00-\x1f]/g, '');
}
module.exports = { requestLogPath };
+19 -14
View File
@@ -21,22 +21,27 @@ function isAllowedOrigin(origin) {
return allowedOrigins.indexOf(origin) !== -1;
}
// Origin check for multipart bodies (see the Content-Type gate below).
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
// set, so an Origin matching the request Host is accepted alongside the CORS
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
function multipartOriginAllowed(req) {
// Check every browser mutation, including an empty form POST. Explicitly
// configured frontend origins may be cross-site; a sibling origin alone is
// not trusted. Non-browser clients without Origin/Fetch Metadata still work.
function mutationOriginAllowed(req) {
// Fetch Metadata is set by the browser and cannot be forged cross-site, so a
// same-origin request is trusted before the Origin/Host/scheme comparison,
// which depends on trust proxy and X-Forwarded-Proto being configured.
const site = req.headers['sec-fetch-site'];
if (site) return site !== 'cross-site';
if (site === 'same-origin') return true;
const origin = req.headers.origin;
if (!origin) return true;
if (isAllowedOrigin(origin)) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
if (origin) {
if (isAllowedOrigin(origin)) return true;
try {
const parsed = new URL(origin);
return parsed.origin !== 'null' && parsed.host === req.headers.host
&& (!req.protocol || parsed.protocol === `${req.protocol}:`);
} catch { return false; }
}
return !site || site === 'none';
}
module.exports = { isAllowedOrigin, multipartOriginAllowed };
// Compatibility export for existing callers.
const multipartOriginAllowed = mutationOriginAllowed;
module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed };
+2 -1
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Route helper utilities for standardized request handling.
* Provides async error wrapping, validation, and response formatting.
@@ -93,7 +94,7 @@ const successResponse = (res, data, statusCode = 200, message = null) => {
*/
const errorResponse = (res, error, statusCode = 500, publicMessage) => {
const message = publicMessage || (error instanceof Error ? error.message : String(error));
const route = res.req ? `${res.req.method} ${res.req.originalUrl}` : null;
const route = res.req ? `${res.req.method} ${requestLogPath(res.req.originalUrl)}` : null;
logger.error(route ? `${route} - ${message}` : message, {
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined
+5 -8
View File
@@ -145,15 +145,12 @@ async function cleanupExpiredRevocations() {
/**
* Initialize cleanup job for expired revocations
*/
function initializeRevocationCleanup() {
// Run cleanup every 6 hours
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
// Run initial cleanup
cleanupExpiredRevocations();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupExpiredRevocations, { interval: 6 * 60 * 60 * 1000, initialDelay: 0 });
function initializeRevocationCleanup() { cleanupTask.start(); }
const stopRevocationCleanup = () => cleanupTask.stop();
module.exports = {
module.exports = { buildTokenId,
stopRevocationCleanup,
revokeToken,
isTokenRevoked,
revokeAllUserTokens,