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:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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')
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user