security: fix missing trip access checks on Immich routes (GHSA-pcr3-6647-jh72)

security: require auth for uploaded photos (GHSA-wxx3-84fc-mrx2)

GHSA-pcr3-6647-jh72 (HIGH):
- Add canAccessTrip check to all /trips/:tripId/photos and
  /trips/:tripId/album-links endpoints
- Prevents authenticated users from accessing other trips' photos

GHSA-wxx3-84fc-mrx2 (LOW):
- /uploads/photos now requires JWT auth token or valid share token
- Covers and avatars remain public (needed for login/share pages)
- Files were already blocked behind auth
This commit is contained in:
Maurice
2026-04-01 15:46:08 +02:00
parent ef5b381f8e
commit 16277a3811
2 changed files with 23 additions and 2 deletions

View File

@@ -131,7 +131,7 @@ import { authenticate } from './middleware/auth';
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
// Serve uploaded photos (public — needed for shared trips)
// Serve uploaded photos — require auth token or valid share token
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(__dirname, '../uploads/photos', safeName);
@@ -140,6 +140,20 @@ app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
return res.status(403).send('Forbidden');
}
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
// Allow if authenticated or if a valid share token is present
const authHeader = req.headers.authorization;
const token = req.query.token as string || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!token) return res.status(401).send('Authentication required');
try {
const jwt = require('jsonwebtoken');
jwt.verify(token, process.env.JWT_SECRET || require('./config').JWT_SECRET);
} catch {
// Check if it's a share token
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
if (!shareRow) return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});

View File

@@ -1,5 +1,5 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
@@ -164,6 +164,7 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const photos = db.prepare(`
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
@@ -182,6 +183,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { asset_ids, shared = true } = req.body;
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
@@ -212,6 +214,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
// Remove a photo from a trip (own photos only)
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(req.params.tripId, authReq.user.id, req.params.assetId);
res.json({ success: true });
@@ -221,6 +224,7 @@ router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res
// Toggle sharing for a specific photo
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { shared } = req.body;
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
@@ -361,6 +365,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
// Get album links for a trip
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, (authReq as AuthRequest).user.id)) return res.status(404).json({ error: 'Trip not found' });
const links = db.prepare(`
SELECT tal.*, u.username
FROM trip_album_links tal
@@ -375,6 +381,7 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { album_id, album_name } = req.body;
if (!album_id) return res.status(400).json({ error: 'album_id required' });