Merge pull request #179 from shanelord01/audit/remediation-clean

Automated Security & Quality Audit via Claude Code
This commit is contained in:
Maurice
2026-03-31 20:53:48 +02:00
committed by GitHub
28 changed files with 540 additions and 81 deletions

View File

@@ -164,7 +164,7 @@ function generateToken(user: { id: number | bigint }) {
return jwt.sign(
{ id: user.id },
JWT_SECRET,
{ expiresIn: '24h' }
{ expiresIn: '24h', algorithm: 'HS256' }
);
}
@@ -321,7 +321,7 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login' },
JWT_SECRET,
{ expiresIn: '5m' }
{ expiresIn: '5m', algorithm: 'HS256' }
);
return res.json({ mfa_required: true, mfa_token });
}
@@ -741,7 +741,7 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
return res.status(400).json({ error: 'Verification token and code are required' });
}
try {
const decoded = jwt.verify(mfa_token, JWT_SECRET) as { id: number; purpose?: string };
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
if (decoded.purpose !== 'mfa_login') {
return res.status(401).json({ error: 'Invalid verification token' });
}

View File

@@ -282,7 +282,9 @@ router.post('/:id/link', authenticate, (req: Request, res: Response) => {
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
id, reservation_id || null, assignment_id || null, place_id || null
);
} catch {}
} catch (err) {
console.error('[Files] Error creating file link:', err instanceof Error ? err.message : err);
}
const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id);
res.json({ success: true, links });

View File

@@ -6,6 +6,29 @@ import { AuthRequest } from '../types';
const router = express.Router();
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
function isValidAssetId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
}
/** Validate that an Immich URL is a safe HTTP(S) URL (no internal/metadata IPs). */
function isValidImmichUrl(raw: string): boolean {
try {
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
const hostname = url.hostname.toLowerCase();
// Block metadata endpoints and localhost
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false;
if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return false;
// Block link-local and loopback ranges
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return false;
if (hostname.endsWith('.internal') || hostname.endsWith('.local')) return false;
return true;
} catch {
return false;
}
}
// ── Immich Connection Settings ──────────────────────────────────────────────
router.get('/settings', authenticate, (req: Request, res: Response) => {
@@ -20,6 +43,9 @@ router.get('/settings', authenticate, (req: Request, res: Response) => {
router.put('/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { immich_url, immich_api_key } = req.body;
if (immich_url && !isValidImmichUrl(immich_url.trim())) {
return res.status(400).json({ error: 'Invalid Immich URL. Must be a valid HTTP(S) URL.' });
}
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immich_url?.trim() || null,
immich_api_key?.trim() || null,
@@ -189,10 +215,10 @@ router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' });
try {
@@ -240,10 +266,10 @@ function authFromQuery(req: Request, res: Response, next: Function) {
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
try {
@@ -264,10 +290,10 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
try {

View File

@@ -428,7 +428,8 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
const attribution = photo.authorAttributions?.[0]?.displayName || null;
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true`
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&skipHttpRedirect=true`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;

View File

@@ -80,11 +80,11 @@ async function discover(issuer: string) {
return doc;
}
function generateToken(user: { id: number; username: string; email: string; role: string }) {
function generateToken(user: { id: number }) {
return jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role },
{ id: user.id },
JWT_SECRET,
{ expiresIn: '24h' }
{ expiresIn: '24h', algorithm: 'HS256' }
);
}
@@ -121,9 +121,15 @@ router.get('/login', async (req: Request, res: Response) => {
try {
const doc = await discover(config.issuer);
const state = crypto.randomBytes(32).toString('hex');
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value;
let redirectUri: string;
if (appUrl) {
redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
} else {
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
}
const inviteToken = req.query.invite as string | undefined;
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
@@ -185,7 +191,7 @@ router.get('/callback', async (req: Request, res: Response) => {
const tokenData = await tokenRes.json() as OidcTokenResponse;
if (!tokenRes.ok || !tokenData.access_token) {
console.error('[OIDC] Token exchange failed:', tokenData);
console.error('[OIDC] Token exchange failed: status', tokenRes.status);
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
}