mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.
The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.
Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.
The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.
Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.
Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
This commit is contained in:
@@ -163,6 +163,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
// The calendar subscription hands out a link that reads the trip without an
|
||||
// account, so it sits behind the same permission as the public share link.
|
||||
const canManageShare = can('share_manage', trip)
|
||||
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
@@ -956,6 +959,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
noteInputRef,
|
||||
@@ -1128,6 +1132,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
noteInputRef,
|
||||
@@ -1275,6 +1280,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setUndoHover={setUndoHover}
|
||||
lastActionLabel={lastActionLabel}
|
||||
canEditDays={canEditDays}
|
||||
canManageShare={canManageShare}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
/>
|
||||
|
||||
@@ -133,6 +133,18 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(setIcsHover).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
|
||||
// The subscription mints a link that reads the trip without an account, so
|
||||
// it needs share_manage; the one-off download is a file this member may
|
||||
// already read. Leaving the entry visible would only produce a dialog whose
|
||||
// enable button the server refuses.
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
|
||||
@@ -31,6 +31,14 @@ interface DayPlanSidebarToolbarProps {
|
||||
setUndoHover: (v: boolean) => void
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
|
||||
* stays open to every member: it is a file they already have the right to
|
||||
* read, while the subscription mints a link that works without an account.
|
||||
* Defaults to true so a caller that has not wired the permission through
|
||||
* keeps today's menu rather than silently losing an entry.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
}
|
||||
@@ -40,7 +48,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
@@ -155,23 +163,25 @@ export function DayPlanSidebarToolbar({
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
{canManageShare && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && (
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
|
||||
Reference in New Issue
Block a user