mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
fix: replace JWT tokens in URL query params with short-lived ephemeral tokens
Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file download links, and Immich asset proxy URLs, leaking into server logs, browser history, and Referer headers. - Add ephemeralTokens service: in-memory single-use tokens with per-purpose TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup - Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints - WebSocket auth now consumes an ephemeral token instead of verifying the JWT directly from the URL; client fetches a fresh token before each connect - File download ?token= query param now accepts ephemeral tokens; Bearer header path continues to accept JWTs for programmatic access - Immich asset proxy replaces authFromQuery JWT injection with ephemeral token consumption - Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async onClick handlers replace the synchronous authUrl() pattern throughout FileManager, PlaceInspector, and MemoriesPanel - Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow overriding the auto-constructed discovery endpoint (required for Authentik and similar providers); exposed in the admin UI and .env.example
This commit is contained in:
@@ -3,6 +3,15 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(baseUrl, 'immich').then(setSrc)
|
||||
}, [baseUrl])
|
||||
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -57,6 +66,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -167,13 +177,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const token = useAuthStore(s => s.token)
|
||||
|
||||
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||
|
||||
const originalUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||
|
||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||
@@ -328,7 +333,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||
outlineOffset: -3,
|
||||
}}>
|
||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
@@ -470,12 +475,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxOriginalSrc('')
|
||||
getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc)
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
}}>
|
||||
|
||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||
|
||||
{/* Other user's avatar */}
|
||||
@@ -592,7 +599,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</button>
|
||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||
<img
|
||||
src={originalUrl(lightboxId, lightboxUserId)}
|
||||
src={lightboxOriginalSrc}
|
||||
alt=""
|
||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user