fix: enforce gallery access and consolidate gallery workflows

Apply shared session, permission, ownership and lifecycle checks across
gallery access, media grants and session restoration. Validate mutation
origins, pin webhook DNS resolution and redact token-bearing request URLs.

Consolidate gallery creation and queries, extract frontend state hooks,
fix hook ordering and resource cleanup, and repair the fresh event schema.
Update affected dependencies and restore excluded CI suites with regression
and cross-database coverage.
This commit is contained in:
Paul Nothaft
2026-09-08 11:10:03 +02:00
parent 895e5ab3cc
commit d1c582396a
115 changed files with 6756 additions and 8512 deletions
@@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => {
});
describe('serving routes use the resolver', () => {
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
@@ -0,0 +1,79 @@
const dns = require('dns');
const http = require('http');
const axios = require('axios');
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
afterEach(() => jest.restoreAllMocks());
it('never performs a second DNS lookup that could reach a private listener', async () => {
const received = jest.fn();
const server = http.createServer((req, res) => { received(); res.end('private'); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://rebind.example:${server.address().port}/hook`;
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
if (typeof opts === 'function') { cb = opts; opts = {}; }
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
});
let options;
try {
const check = await validateExternalUrlAsync(url);
options = pinnedRequestOptions(check);
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
expect(preflight).toHaveBeenCalledTimes(1);
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
} finally {
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
await new Promise(resolve => server.close(resolve));
}
});
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
const hosts = [];
const server = http.createServer((req, res) => {
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const host = `pinned.example:${server.address().port}`;
// Test the transport in isolation: production checks reject this private IP.
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
try {
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
});
it('fails closed for missing DNS results', () => {
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
});
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
const fs = require('fs/promises');
const path = require('path');
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
const certificate = await fs.readFile(cert);
const seen = [];
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const makeOptions = hostname => {
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
options.httpsAgent.options.ca = certificate;
return options;
};
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
try {
const host = `pinned.example:${server.address().port}`;
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
expect(seen).toHaveLength(1);
} finally {
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
await new Promise(resolve => server.close(resolve));
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,27 @@
const EventEmitter = require('events');
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
const logger = require('../../src/utils/logger');
const middleware = require('../../src/middleware/apiRequestLogger');
const { requestLogPath } = require('../../src/utils/requestLogPath');
const marker = 'SECRET_TEST_CAPABILITY';
it.each([
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
`/api/gallery/g/verify-token/${marker}`,
`/api/gallery/g/show/${marker}/state`,
`/api/images/g/photo/1/signed/${marker}`,
`/api/secure-images/g/secure/1/${marker}`,
`/api/secure-images/g/secure-download/1/${marker}`,
`/api/public/contracts/${marker}/sign`,
`/api/customer/auth/password-reset/${marker}`,
])('does not log capabilities on request or response: %s', (originalUrl) => {
logger.info.mockClear();
const res = new EventEmitter(); res.statusCode = 200;
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
res.emit('finish');
expect(logger.info).toHaveBeenCalledTimes(2);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
});
it('retains useful non-secret routes and removes control characters', () => {
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
});
@@ -0,0 +1,13 @@
const http = require('http');
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
jest.resetModules();
const request = require('supertest');
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
try {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('actual test listener');
expect(response.request.url).toContain('://[::1]:');
} finally { await new Promise(resolve => server.close(resolve)); }
});