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:
@@ -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,28 @@
|
||||
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}`,
|
||||
`/api/public/newsletter/unsubscribe/${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)); }
|
||||
});
|
||||
Reference in New Issue
Block a user