VYPR
Medium severity5.3NVD Advisory· Published May 28, 2026

CVE-2026-45410

CVE-2026-45410

Description

TREK is a collaborative travel planner. Prior to 3.0.18, early return on missing user during login flow allowed an attacker to enumerate valid user accounts via response timing discrepancy. When an email address existed in the database, the backend performed a bcrypt password comparison before returning a 401 Unauthorized, adding ~370 ms of latency. When the email did not exist, the backend returned immediately (~10 ms). This ~14× timing difference could be detected without any difference in HTTP status codes or response bodies. This vulnerability is fixed in 3.0.18.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

TREK collaborative travel planner prior to 3.0.18 allows user enumeration via a response timing side-channel (~14x difference) in the login endpoint.

Vulnerability

TREK collaborative travel planner versions prior to 3.0.18 contain a timing-based user enumeration vulnerability in the POST /api/auth/login endpoint [2]. When an email address does not exist in the database, the backend returns immediately with a 401 Unauthorized status code (~10 ms). When the email exists, the backend performs a bcrypt password comparison before returning the same 401 status code, adding approximately 370 ms of latency [2]. The response body and HTTP status code are identical in both cases [2].

Exploitation

An unauthenticated attacker can exploit this by sending login requests with candidate email addresses and measuring response times. The attacker does not need any authentication or special network position; they only need to be able to send HTTP requests to the endpoint. By interleaving known-valid and known-invalid email addresses and collecting timing samples (e.g., using a script similar to the published PoC), the attacker can consistently observe the ~14× timing gap between existing and non-existing users [1][2]. No rate-limiting bypass is required, though patience may be needed to collect enough samples [2].

Impact

Successful exploitation allows an unauthenticated attacker to enumerate valid email addresses registered in the TREK application. The attacker gains no direct access to accounts or data, but the enumerated emails can be used for subsequent attacks, such as password spraying, credential stuffing, or targeted phishing campaigns against confirmed accounts [2]. This information disclosure violates the confidentiality of user presence and can serve as a stepping stone for more severe compromises.

Mitigation

The vulnerability is fixed in TREK version 3.0.18 [2]. Users should upgrade to at least this version. No workarounds are documented in the available references. If immediate upgrade is not possible, monitoring for unusual request patterns or implementing a uniform delay on the login endpoint could reduce the exploitability, but these mitigations are not officially provided.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Mauriceboe/Trekinferred2 versions
    <3.0.18+ 1 more
    • (no CPE)range: <3.0.18
    • (no CPE)range: < 3.0.18

Patches

1
e7b419d3971e

security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)

https://github.com/mauriceboe/TREKJulien G.May 10, 2026Fixed in 3.0.18via llm-release-walk
18 files changed · +983 2979
  • client/package-lock.json+356 1737 modified
  • client/src/components/Planner/DayPlanSidebar.tsx+15 113 modified
    @@ -23,6 +23,11 @@ import { useCanDo } from '../../store/permissionsStore'
     import { useSettingsStore } from '../../store/settingsStore'
     import { useTranslation } from '../../i18n'
     import { isDayInAccommodationRange } from '../../utils/dayOrder'
    +import {
    +  TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
    +  getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
    +  type MergedItem,
    +} from '../../utils/dayMerge'
     import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
     import { useDayNotes } from '../../hooks/useDayNotes'
     import Tooltip from '../shared/Tooltip'
    @@ -362,26 +367,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
         })
       }
     
    -  const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
    -
    -  // Get span phase: how a reservation relates to a specific day (by id)
    -  const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
    -    const startDayId = r.day_id
    -    const endDayId = r.end_day_id ?? startDayId
    -    if (!startDayId || startDayId === endDayId) return 'single'
    -    if (dayId === startDayId) return 'start'
    -    if (dayId === endDayId) return 'end'
    -    return 'middle'
    -  }
    -
    -  // Get the appropriate display time for a reservation on a specific day
    -  const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
    -    const phase = getSpanPhase(r, dayId)
    -    if (phase === 'end') return r.reservation_end_time || null
    -    if (phase === 'middle') return null
    -    return r.reservation_time || null
    -  }
    -
       // Get phase label for multi-day badge
       const getSpanLabel = (r: Reservation, phase: string): string | null => {
         if (phase === 'single') return null
    @@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
         return { day_id: startId, end_day_id: targetDayId }
       }
     
    -  const getTransportForDay = (dayId: number) => {
    -    const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
    -    return reservations.filter(r => {
    -      if (!TRANSPORT_TYPES.has(r.type)) return false
    -      if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
    -
    -      const startDayId = r.day_id
    -      const endDayId = r.end_day_id ?? startDayId
    -
    -      if (startDayId == null) return false
    -
    -      if (endDayId !== startDayId) {
    -        const startDay = days.find(d => d.id === startDayId)
    -        const endDay = days.find(d => d.id === endDayId)
    -        const thisDay = days.find(d => d.id === dayId)
    -        if (!startDay || !endDay || !thisDay) return false
    -        return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
    -      }
    -      return startDayId === dayId
    -    })
    -  }
    +  const getTransportForDay = (dayId: number) =>
    +    _getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
     
       // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
       const getActiveRentalsForDay = (dayId: number) => {
    @@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
       const getDayAssignments = (dayId) =>
         (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
     
    -  // Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
    -  const parseTimeToMinutes = (time?: string | null): number | null => {
    -    if (!time) return null
    -    // ISO-Format "2025-03-30T09:00:00"
    -    if (time.includes('T')) {
    -      const [h, m] = time.split('T')[1].split(':').map(Number)
    -      return h * 60 + m
    -    }
    -    // Einfaches "HH:MM" Format
    -    const parts = time.split(':').map(Number)
    -    if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
    -    return null
    -  }
    -
       // Compute initial day_plan_position for a transport based on time
       const computeTransportPosition = (r, da) => {
         const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
    @@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
         reservationsApi.updatePositions(tripId, positions).catch(() => {})
       }
     
    -  const getMergedItems = (dayId) => {
    -    const da = getDayAssignments(dayId)
    -    const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
    -    const transport = getTransportForDay(dayId)
    -
    -    // All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
    -    const baseItems = [
    -      ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
    -      ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
    -    ].sort((a, b) => a.sortKey - b.sortKey)
    -
    -    // Transports are inserted among places based on time
    -    const timedTransports = transport.map(r => ({
    -      type: 'transport' as const,
    -      data: r,
    -      minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
    -    })).sort((a, b) => a.minutes - b.minutes)
    -
    -    if (timedTransports.length === 0) return baseItems
    -    if (baseItems.length === 0) {
    -      return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
    -    }
    -
    -    // Insert transports among places based on per-day position or time
    -    const result = [...baseItems]
    -    for (let ti = 0; ti < timedTransports.length; ti++) {
    -      const timed = timedTransports[ti]
    -      const minutes = timed.minutes
    -
    -      // Use per-day position if explicitly set by user reorder
    -      const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
    -      if (perDayPos != null) {
    -        result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
    -        continue
    -      }
    -
    -      // Find insertion position: after the last place with time <= this transport's time
    -      let insertAfterKey = -Infinity
    -      for (const item of result) {
    -        if (item.type === 'place') {
    -          const pm = parseTimeToMinutes(item.data?.place?.place_time)
    -          if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
    -        } else if (item.type === 'transport') {
    -          const tm = parseTimeToMinutes(item.data?.reservation_time)
    -          if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
    -        }
    -      }
    -
    -      const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
    -      const sortKey = insertAfterKey === -Infinity
    -        ? lastKey + 0.5 + ti * 0.01
    -        : insertAfterKey + 0.01 + ti * 0.001
    -
    -      result.push({ type: timed.type, sortKey, data: timed.data })
    -    }
    -
    -    return result.sort((a, b) => a.sortKey - b.sortKey)
    -  }
    +  const getMergedItems = (dayId: number): MergedItem[] =>
    +    _getMergedItems({
    +      dayAssignments: getDayAssignments(dayId),
    +      dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
    +      dayTransports: getTransportForDay(dayId),
    +      dayId,
    +      getDisplayTime: getDisplayTimeForDay,
    +    })
     
       // Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
       // eslint-disable-next-line react-hooks/exhaustive-deps
    
  • client/src/pages/SharedTripPage.tsx+10 8 modified
    @@ -11,8 +11,8 @@ import { createElement } from 'react'
     import { renderToStaticMarkup } from 'react-dom/server'
     import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
     import { isDayInAccommodationRange } from '../utils/dayOrder'
    +import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
     
    -const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
     const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
     
     function createMarkerIcon(place: any) {
    @@ -184,14 +184,16 @@ export default function SharedTripPage() {
               {sortedDays.map((day: any, di: number) => {
                 const da = assignments[String(day.id)] || []
                 const notes = (dayNotes[String(day.id)] || [])
    -            const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
    +            const dayAssignmentIds: number[] = da.map((a: any) => a.id)
    +            const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
                 const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
     
    -            const merged = [
    -              ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
    -              ...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
    -              ...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
    -            ].sort((a, b) => a.k - b.k)
    +            const merged = getMergedItems({
    +              dayAssignments: da,
    +              dayNotes: notes,
    +              dayTransports: dayTransport,
    +              dayId: day.id,
    +            })
     
                 return (
                   <div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
    @@ -212,7 +214,7 @@ export default function SharedTripPage() {
     
                     {selectedDay === day.id && merged.length > 0 && (
                       <div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
    -                    {merged.map((item: any, idx: number) => {
    +                    {merged.map((item: any) => {
                           if (item.type === 'transport') {
                             const r = item.data
                             const TIcon = TRANSPORT_ICONS[r.type] || Ticket
    
  • client/src/types.ts+1 0 modified
    @@ -175,6 +175,7 @@ export interface Reservation {
       accommodation_start_day_id?: number | null
       accommodation_end_day_id?: number | null
       day_plan_position?: number | null
    +  day_positions?: Record<number, number> | null
       metadata?: Record<string, string> | string | null
       needs_review?: number
       endpoints?: ReservationEndpoint[]
    
  • client/src/utils/dayMerge.test.ts+127 0 added
    @@ -0,0 +1,127 @@
    +import { describe, it, expect } from 'vitest'
    +import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
    +
    +describe('parseTimeToMinutes', () => {
    +  it('parses HH:MM string', () => {
    +    expect(parseTimeToMinutes('09:30')).toBe(570)
    +  })
    +
    +  it('parses ISO datetime string', () => {
    +    expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
    +  })
    +
    +  it('returns null for null/empty', () => {
    +    expect(parseTimeToMinutes(null)).toBeNull()
    +    expect(parseTimeToMinutes(undefined)).toBeNull()
    +  })
    +})
    +
    +describe('getSpanPhase', () => {
    +  it('returns single when start === end', () => {
    +    expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
    +  })
    +
    +  it('returns start for the departure day', () => {
    +    expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
    +  })
    +
    +  it('returns end for the arrival day', () => {
    +    expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
    +  })
    +
    +  it('returns middle for days in between', () => {
    +    expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
    +  })
    +})
    +
    +describe('getDisplayTimeForDay', () => {
    +  const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
    +
    +  it('returns reservation_time on start day', () => {
    +    expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
    +  })
    +
    +  it('returns reservation_end_time on end day', () => {
    +    expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
    +  })
    +
    +  it('returns null for middle day', () => {
    +    expect(getDisplayTimeForDay(r, 2)).toBeNull()
    +  })
    +})
    +
    +describe('getTransportForDay', () => {
    +  const days = [
    +    { id: 1, day_number: 1 },
    +    { id: 2, day_number: 2 },
    +    { id: 3, day_number: 3 },
    +  ]
    +
    +  it('excludes non-transport types', () => {
    +    const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
    +    expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
    +  })
    +
    +  it('includes single-day transport on the correct day', () => {
    +    const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
    +    expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
    +    expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
    +  })
    +
    +  it('includes multi-day transport on all spanned days', () => {
    +    const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
    +    expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
    +    expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
    +    expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
    +  })
    +
    +  it('excludes transport linked to an assignment on that day', () => {
    +    const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
    +    expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
    +    expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
    +  })
    +})
    +
    +describe('getMergedItems', () => {
    +  it('merges places and notes sorted by sortKey', () => {
    +    const dayAssignments = [
    +      { id: 1, order_index: 0, place: { place_time: null } },
    +      { id: 2, order_index: 2, place: { place_time: null } },
    +    ]
    +    const dayNotes = [{ id: 10, sort_order: 1 }]
    +    const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
    +    expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
    +    expect(result[0].data.id).toBe(1)
    +    expect(result[1].data.id).toBe(10)
    +    expect(result[2].data.id).toBe(2)
    +  })
    +
    +  it('inserts transport by time when no per-day position is set', () => {
    +    const dayAssignments = [
    +      { id: 1, order_index: 0, place: { place_time: '08:00' } },
    +      { id: 2, order_index: 1, place: { place_time: '13:00' } },
    +    ]
    +    const dayTransports = [
    +      { id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
    +    ]
    +    const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
    +    const types = result.map(i => i.type)
    +    // transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
    +    expect(types).toEqual(['place', 'transport', 'place'])
    +  })
    +
    +  it('per-day position overrides time-based insertion', () => {
    +    const dayAssignments = [
    +      { id: 1, order_index: 0, place: { place_time: '08:00' } },
    +      { id: 2, order_index: 1, place: { place_time: '13:00' } },
    +    ]
    +    // Transport at 10:30 would normally go between the two places
    +    // but per-day position 1.5 puts it after the second place
    +    const dayTransports = [
    +      { id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
    +    ]
    +    const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
    +    const types = result.map(i => i.type)
    +    expect(types).toEqual(['place', 'place', 'transport'])
    +  })
    +})
    
  • client/src/utils/dayMerge.ts+136 0 added
    @@ -0,0 +1,136 @@
    +export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
    +
    +export interface MergedItem {
    +  type: 'place' | 'note' | 'transport'
    +  sortKey: number
    +  data: any
    +}
    +
    +export function parseTimeToMinutes(time?: string | null): number | null {
    +  if (!time) return null
    +  if (time.includes('T')) {
    +    const [h, m] = time.split('T')[1].split(':').map(Number)
    +    return h * 60 + m
    +  }
    +  const parts = time.split(':').map(Number)
    +  if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
    +  return null
    +}
    +
    +export function getSpanPhase(
    +  r: { day_id?: number | null; end_day_id?: number | null },
    +  dayId: number
    +): 'single' | 'start' | 'middle' | 'end' {
    +  const startDayId = r.day_id
    +  const endDayId = r.end_day_id ?? startDayId
    +  if (!startDayId || startDayId === endDayId) return 'single'
    +  if (dayId === startDayId) return 'start'
    +  if (dayId === endDayId) return 'end'
    +  return 'middle'
    +}
    +
    +export function getDisplayTimeForDay(
    +  r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
    +  dayId: number
    +): string | null {
    +  const phase = getSpanPhase(r, dayId)
    +  if (phase === 'end') return r.reservation_end_time || null
    +  if (phase === 'middle') return null
    +  return r.reservation_time || null
    +}
    +
    +/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
    +export function getTransportForDay(opts: {
    +  reservations: any[]
    +  dayId: number
    +  dayAssignmentIds: number[]
    +  days: Array<{ id: number; day_number?: number }>
    +}): any[] {
    +  const { reservations, dayId, dayAssignmentIds, days } = opts
    +
    +  const getDayOrder = (id: number): number => {
    +    const d = days.find(x => x.id === id)
    +    return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
    +  }
    +  const thisDayOrder = getDayOrder(dayId)
    +
    +  return reservations.filter(r => {
    +    if (!TRANSPORT_TYPES.has(r.type)) return false
    +    if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
    +
    +    const startDayId = r.day_id
    +    const endDayId = r.end_day_id ?? startDayId
    +
    +    if (startDayId == null) return false
    +
    +    if (endDayId !== startDayId) {
    +      const startOrder = getDayOrder(startDayId)
    +      const endOrder = getDayOrder(endDayId)
    +      return thisDayOrder >= startOrder && thisDayOrder <= endOrder
    +    }
    +    return startDayId === dayId
    +  })
    +}
    +
    +/** Merge places, notes, and transports into a single ordered day timeline. */
    +export function getMergedItems(opts: {
    +  dayAssignments: any[]
    +  dayNotes: any[]
    +  dayTransports: any[]
    +  dayId: number
    +  getDisplayTime?: (r: any, dayId: number) => string | null
    +}): MergedItem[] {
    +  const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
    +  const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
    +
    +  const baseItems: MergedItem[] = [
    +    ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
    +    ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
    +  ].sort((a, b) => a.sortKey - b.sortKey)
    +
    +  const timedTransports = transport.map(r => ({
    +    type: 'transport' as const,
    +    data: r,
    +    minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
    +  })).sort((a, b) => a.minutes - b.minutes)
    +
    +  if (timedTransports.length === 0) return baseItems
    +  if (baseItems.length === 0) {
    +    return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
    +  }
    +
    +  // Insert transports among base items based on per-day position or time
    +  const result = [...baseItems]
    +  for (let ti = 0; ti < timedTransports.length; ti++) {
    +    const timed = timedTransports[ti]
    +    const minutes = timed.minutes
    +
    +    // Per-day position takes precedence (set by user reorder)
    +    const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
    +    if (perDayPos != null) {
    +      result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
    +      continue
    +    }
    +
    +    // Time-based fallback: insert after the last item whose time <= this transport's time
    +    let insertAfterKey = -Infinity
    +    for (const item of result) {
    +      if (item.type === 'place') {
    +        const pm = parseTimeToMinutes(item.data?.place?.place_time)
    +        if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
    +      } else if (item.type === 'transport') {
    +        const tm = parseTimeToMinutes(item.data?.reservation_time)
    +        if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
    +      }
    +    }
    +
    +    const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
    +    const sortKey = insertAfterKey === -Infinity
    +      ? lastKey + 0.5 + ti * 0.01
    +      : insertAfterKey + 0.01 + ti * 0.001
    +
    +    result.push({ type: timed.type, sortKey, data: timed.data })
    +  }
    +
    +  return result.sort((a, b) => a.sortKey - b.sortKey)
    +}
    
  • Dockerfile+7 4 modified
    @@ -1,27 +1,30 @@
     # Stage 1: Build React client
    -FROM node:22-alpine AS client-builder
    +FROM node:24-alpine AS client-builder
     WORKDIR /app/client
     COPY client/package*.json ./
     RUN npm ci
     COPY client/ ./
     RUN npm run build
     
     # Stage 2: Production server
    -FROM node:22-alpine
    +FROM node:24-alpine
     
     WORKDIR /app
     
     # Timezone support + native deps (better-sqlite3 needs build tools)
     COPY server/package*.json ./
     RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
         npm ci --production && \
    -    apk del python3 make g++
    +    rm package-lock.json && \
    +    apk del python3 make g++ && \
    +    rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
     
     COPY server/ ./
     COPY --from=client-builder /app/client/dist ./public
     COPY --from=client-builder /app/client/public/fonts ./public/fonts
     
    -RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
    +RUN rm -f package-lock.json && \
    +    mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
         mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
         chown -R node:node /app
     
    
  • .github/workflows/security.yml+37 0 added
    @@ -0,0 +1,37 @@
    +name: Security Scan
    +
    +on:
    +  pull_request:
    +    branches: [main]
    +  push:
    +    branches: [main]
    +
    +permissions:
    +  pull-requests: write
    +
    +jobs:
    +  scout:
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v4
    +
    +      - uses: docker/setup-buildx-action@v3
    +
    +      - uses: docker/build-push-action@v5
    +        with:
    +          context: .
    +          push: false
    +          load: true
    +          tags: trek:scan
    +
    +      - uses: docker/login-action@v3
    +        with:
    +          username: ${{ secrets.DOCKERHUB_USERNAME }}
    +          password: ${{ secrets.DOCKERHUB_TOKEN }}
    +
    +      - uses: docker/scout-action@v1
    +        with:
    +          command: cves
    +          image: trek:scan
    +          only-severities: critical,high
    +          exit-code: true
    
  • SECURITY.md+1 1 modified
    @@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
     If you discover a security vulnerability, please report it responsibly:
     
     1. **Do not** open a public issue
    -2. Email: **mauriceboe@icloud.com**
    +2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
     3. Include a description of the vulnerability and steps to reproduce
     
     You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
    
  • server/package.json+4 2 modified
    @@ -40,8 +40,10 @@
         "zod": "^4.3.6"
       },
       "overrides": {
    -    "hono": "^4.12.12",
    -    "@hono/node-server": "^1.19.13"
    +    "hono": "^4.12.16",
    +    "@hono/node-server": "^1.19.13",
    +    "picomatch": "^4.0.4",
    +    "ip-address": "^10.1.1"
       },
       "devDependencies": {
         "@types/archiver": "^7.0.0",
    
  • server/package-lock.json+76 1105 modified
  • server/src/routes/auth.ts+9 3 modified
    @@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
       res.status(201).json({ token: result.token, user: result.user });
     });
     
    -router.post('/login', authLimiter, (req: Request, res: Response) => {
    +router.post('/login', authLimiter, async (req: Request, res: Response) => {
    +  const started = Date.now();
       const result = loginUser(req.body);
       if (result.auditAction) {
         writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
       }
    +  const elapsed = Date.now() - started;
    +  if (elapsed < LOGIN_MIN_LATENCY_MS) {
    +    await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
    +  }
       if (result.error) return res.status(result.status!).json({ error: result.error });
       if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
       setAuthCookie(res, result.token!, req);
    @@ -166,9 +171,10 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
     // Generic OK response — identical regardless of email existence, to
     // prevent enumeration via response body OR status code.
     const GENERIC_FORGOT_RESPONSE = { ok: true };
    -// Minimum time we spend inside the forgot handler so a "no such user"
    -// path does not complete noticeably faster than a real reset.
    +// Minimum time we spend inside the forgot/login handlers so a "no such
    +// user" path does not complete noticeably faster than a real operation.
     const FORGOT_MIN_LATENCY_MS = 350;
    +const LOGIN_MIN_LATENCY_MS = 350;
     
     router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
       const started = Date.now();
    
  • server/src/services/authService.ts+17 2 modified
    @@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
     
     authenticator.options = { window: 1 };
     
    +// Pre-computed bcrypt hash to equalise timing of "unknown email" and
    +// "OIDC-only account" branches with the real verification path (CWE-208).
    +// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
    +const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
    +
     const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
     const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
     const MFA_BACKUP_CODE_COUNT = 10;
    @@ -437,14 +442,24 @@ export function loginUser(body: {
       }
     
       const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
    +
    +  // Always run bcrypt — even for unknown/OIDC-only users — so response time
    +  // does not reveal whether the email exists in the database (CWE-203/208).
    +  const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH;
    +  const validPassword = bcrypt.compareSync(password, hashToCheck);
    +
       if (!user) {
         return {
           error: 'Invalid email or password', status: 401,
           auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
         };
       }
    -
    -  const validPassword = bcrypt.compareSync(password, user.password_hash!);
    +  if (!user.password_hash) {
    +    return {
    +      error: 'Invalid email or password', status: 401,
    +      auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
    +    };
    +  }
       if (!validPassword) {
         return {
           error: 'Invalid email or password', status: 401,
    
  • server/src/services/shareService.ts+20 4 modified
    @@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
           JOIN places p ON da.place_id = p.id
           LEFT JOIN categories c ON p.category_id = c.id
           WHERE da.day_id IN (${ph})
    -      ORDER BY da.order_index ASC
    +      ORDER BY da.order_index ASC, da.created_at ASC
         `).all(...dayIds);
     
         const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
    @@ -137,7 +137,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
         }
         assignments = byDay;
     
    -    const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
    +    const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
         const notesByDay: Record<number, any[]> = {};
         for (const n of allNotes as any[]) {
           if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
    @@ -153,8 +153,24 @@ export function getSharedTripData(token: string): Record<string, any> | null {
         WHERE p.trip_id = ? ORDER BY p.created_at DESC
       `).all(tripId);
     
    -  // Reservations
    -  const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
    +  // Reservations — include per-day positions so the client can render the same order as the planner
    +  const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
    +
    +  const dayPositions = db.prepare(`
    +    SELECT rdp.reservation_id, rdp.day_id, rdp.position
    +    FROM reservation_day_positions rdp
    +    JOIN reservations r ON rdp.reservation_id = r.id
    +    WHERE r.trip_id = ?
    +  `).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
    +
    +  const posMap = new Map<number, Record<number, number>>();
    +  for (const dp of dayPositions) {
    +    if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
    +    posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
    +  }
    +  for (const r of reservations) {
    +    r.day_positions = posMap.get(r.id) || null;
    +  }
     
       // Accommodations
       const accommodations = db.prepare(`
    
  • server/src/services/tripService.ts+4 0 modified
    @@ -7,6 +7,7 @@ import { listBudgetItems } from './budgetService';
     import { listItems as listPackingItems } from './packingService';
     import { listReservations } from './reservationService';
     import { listNotes as listCollabNotes } from './collabService';
    +import { shiftOwnerEntriesForTripWindow } from './vacayService';
     
     export const MS_PER_DAY = 86400000;
     export const MAX_TRIP_DAYS = 365;
    @@ -240,6 +241,9 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
         WHERE id=?
       `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
     
    +  if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
    +    shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
    +
       const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
       if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
         generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
    
  • server/src/services/vacayService.ts+23 0 modified
    @@ -101,6 +101,29 @@ export function getActivePlanId(userId: number): number {
       return getActivePlan(userId).id;
     }
     
    +export function shiftOwnerEntriesForTripWindow(
    +  ownerId: number,
    +  oldStart: string,
    +  oldEnd: string,
    +  newStart: string
    +): void {
    +  const row = db.prepare(
    +    'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days'
    +  ).get(newStart, oldStart) as { days: number } | undefined;
    +  const offset = row?.days ?? 0;
    +  if (offset === 0) return;
    +
    +  const plan = getOwnPlan(ownerId);
    +
    +  db.prepare(
    +    `UPDATE OR IGNORE vacay_entries
    +        SET date = date(date, ? || ' days')
    +      WHERE plan_id = ?
    +        AND user_id = ?
    +        AND date BETWEEN ? AND ?`
    +  ).run(`${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd);
    +}
    +
     export function getPlanUsers(planId: number): VacayUser[] {
       const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
       if (!plan) return [];
    
  • server/tests/integration/share.test.ts+58 0 modified
    @@ -285,3 +285,61 @@ describe('Shared trip — day assignments and notes', () => {
         expect(res.body.assignments).toEqual({});
       });
     });
    +
    +describe('Shared trip — ordering parity (issue #981)', () => {
    +  it('SHARE-014 — assignments with same order_index are ordered by created_at (tiebreaker)', async () => {
    +    const { user } = createUser(testDb);
    +    const trip = createTrip(testDb, user.id);
    +    const day = createDay(testDb, trip.id, { date: '2025-09-01' });
    +    const place1 = createPlace(testDb, trip.id, { name: 'First Created' });
    +    const place2 = createPlace(testDb, trip.id, { name: 'Second Created' });
    +
    +    // Both with order_index = 0 (schema default) but different created_at
    +    testDb.prepare(
    +      "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
    +    ).run(day.id, place1.id);
    +    testDb.prepare(
    +      "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
    +    ).run(day.id, place2.id);
    +
    +    const { body: { token } } = await request(app)
    +      .post(`/api/trips/${trip.id}/share-link`)
    +      .set('Cookie', authCookie(user.id))
    +      .send({});
    +
    +    const res = await request(app).get(`/api/shared/${token}`);
    +    expect(res.status).toBe(200);
    +    const assignments = res.body.assignments[day.id];
    +    expect(assignments).toHaveLength(2);
    +    expect(assignments[0].place.name).toBe('First Created');
    +    expect(assignments[1].place.name).toBe('Second Created');
    +  });
    +
    +  it('SHARE-015 — reservations include day_positions map from reservation_day_positions table', async () => {
    +    const { user } = createUser(testDb);
    +    const trip = createTrip(testDb, user.id);
    +    const day = createDay(testDb, trip.id, { date: '2025-09-01' });
    +
    +    const res1 = testDb.prepare(
    +      "INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
    +    ).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
    +    const reservationId = Number(res1.lastInsertRowid);
    +
    +    // Insert a per-day position
    +    testDb.prepare(
    +      'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
    +    ).run(reservationId, day.id, 1.5);
    +
    +    const { body: { token } } = await request(app)
    +      .post(`/api/trips/${trip.id}/share-link`)
    +      .set('Cookie', authCookie(user.id))
    +      .send({ share_bookings: true });
    +
    +    const shareRes = await request(app).get(`/api/shared/${token}`);
    +    expect(shareRes.status).toBe(200);
    +    const reservation = shareRes.body.reservations.find((r: any) => r.id === reservationId);
    +    expect(reservation).toBeDefined();
    +    expect(reservation.day_positions).toBeDefined();
    +    expect(reservation.day_positions[day.id]).toBe(1.5);
    +  });
    +});
    
  • server/tests/unit/mcp/tools-trips.test.ts+82 0 modified
    @@ -184,6 +184,88 @@ describe('Tool: update_trip', () => {
           expect(result.isError).toBe(true);
         });
       });
    +
    +  it('shifts owner vacay entries when update_trip moves trip window by fixed offset', async () => {
    +    const { user } = createUser(testDb);
    +    const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-09' });
    +
    +    // Materialize active vacay plan for owner and entries in old trip window.
    +    const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
    +    const planId = Number(planRes.lastInsertRowid);
    +    testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
    +    testDb.prepare(
    +        'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
    +    ).run(user.id, planId, 2026);
    +    for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
    +      testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
    +    }
    +
    +    await withHarness(user.id, async (h) => {
    +      const result = await h.client.callTool({
    +        name: 'update_trip',
    +        arguments: { tripId: trip.id, start_date: '2026-08-08', end_date: '2026-08-16' },
    +      });
    +      const data = parseToolResult(result) as any;
    +      expect(data.trip.start_date).toBe('2026-08-08');
    +      expect(data.trip.end_date).toBe('2026-08-16');
    +    });
    +
    +    const oldWindow = testDb.prepare(
    +        "SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
    +    ).all(planId, user.id) as { date: string }[];
    +    expect(oldWindow).toHaveLength(0);
    +
    +    const shifted = testDb.prepare(
    +        "SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
    +    ).all(planId, user.id) as { date: string }[];
    +    expect(shifted.map(r => r.date)).toEqual([
    +      '2026-08-10',
    +      '2026-08-11',
    +      '2026-08-12',
    +      '2026-08-13',
    +      '2026-08-14',
    +    ]);
    +  });
    +
    +  it('shifts entries from the owners own plan even if another vacay plan is active', async () => {
    +    const { user } = createUser(testDb);
    +    const { user: otherOwner } = createUser(testDb);
    +    const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-07' });
    +
    +    // Own plan with entries that should be shifted.
    +    const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
    +    const ownPlanId = Number(ownPlanRes.lastInsertRowid);
    +    testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
    +    testDb.prepare(
    +        'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
    +    ).run(user.id, ownPlanId, 2026);
    +    for (const d of ['2026-09-02', '2026-09-03']) {
    +      testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
    +    }
    +
    +    // Different accepted plan becomes "active" for the owner.
    +    const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
    +    const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
    +    testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
    +
    +    await withHarness(user.id, async (h) => {
    +      const result = await h.client.callTool({
    +        name: 'update_trip',
    +        arguments: { tripId: trip.id, start_date: '2026-09-08', end_date: '2026-09-14' },
    +      });
    +      expect(result.isError).toBeFalsy();
    +    });
    +
    +    const oldWindow = testDb.prepare(
    +        "SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
    +    ).all(ownPlanId, user.id) as { date: string }[];
    +    expect(oldWindow).toHaveLength(0);
    +
    +    const shifted = testDb.prepare(
    +        "SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
    +    ).all(ownPlanId, user.id) as { date: string }[];
    +    expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
    +  });
     });
     
     // ---------------------------------------------------------------------------
    

Vulnerability mechanics

Root cause

"Early return on missing user during login flow skips bcrypt password comparison, creating a measurable timing side-channel."

Attack vector

An unauthenticated attacker sends login requests to the POST /api/auth/login endpoint with different email addresses. When the email does not exist in the database, the backend returns a 401 Unauthorized in ~10 ms. When the email does exist, bcrypt.compareSync runs before returning the same 401 status, adding ~370 ms of latency [ref_id=1]. This ~14× timing difference is detectable from the network without any difference in HTTP status codes or response bodies, allowing the attacker to enumerate valid registered email addresses [CWE-208].

Affected code

The vulnerability exists in the POST /api/auth/login endpoint. The authentication handler looked up the user by email and returned early if no user was found, skipping the bcrypt password hashing step [ref_id=1]. The patch modifies this logic in the server-side login handler to always perform bcrypt.compareSync using a DUMMY_PASSWORD_HASH for unknown accounts [patch_id=3014231].

What the fix does

The patch equalizes login response timing by always running bcrypt.compareSync regardless of whether the email exists, using a module-scope DUMMY_PASSWORD_HASH for unknown or OIDC-only accounts [patch_id=3014231]. Additionally, the login handler is wrapped in a 350 ms minimum-latency pad as defence-in-depth against CPU jitter and future code-path drift. This ensures that both existing and non-existing email addresses produce indistinguishable response times, closing the timing side-channel.

Preconditions

  • authNo authentication required — the attacker can be unauthenticated
  • networkNetwork access to the POST /api/auth/login endpoint
  • inputAbility to measure HTTP response timing (e.g., via script)

Reproduction

The advisory references a PoC gist at https://gist.github.com/jubnl/c2402adf85d946c1730867aeecc794de that sends interleaved requests for a known-valid and a known-invalid email address, collects timing samples from 401 responses only, and reports median response times per category [ref_id=1].

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.