Summary
Packing items in TREK have three visibility tiers: Common (is_private = 0, everyone on the trip), Personal (is_private = 1, the owner only) and Shared (is_private = 1 plus rows in packing_item_recipients).
The read path enforces this. PackingService.listItems() filters on is_private = 0 OR owner_id = ? OR EXISTS (SELECT 1 FROM packing_item_recipients ...), so a member's own list correctly omits items they may not see.
Every other path enforces only trip-level access. TripAccessGuard plus @RequirePermission('packing_edit') establish that the caller is a member of the trip who may edit packing, and the service methods then load the item scoped to trip_id alone. Knowing (or guessing) an item id is therefore enough for any trip member to reach an item that is not visible to them.
Impact
An authenticated user who is already a member of a shared trip can, for packing items belonging to other members:
- Disclose them. The update response returns the full enriched item, including
name, category, quantity, weight_grams, owner_username and the recipient list.
- Modify them (rename, tick, re-weigh, move to a bag, and flip
is_private).
- Delete them.
- Clone them into an item owned by the attacker, which makes the copy permanently visible to them.
- Launder them. Copying the trip recreates every Personal and Shared item as a Common item in the new trip, visible to everyone there.
- Persist them into a packing template, name and category included.
Packing-list contents can be sensitive (medication, medical aids, gifts, intimate items), and the owner's identity is disclosed alongside the item.
This is not remotely exploitable by an anonymous user: the attacker must hold an account that is already a member of the trip. Membership in a shared trip is the only privilege required, and the default member role carries packing_edit.
Affected code paths
All of these reach a service method that filters by trip_id only:
| Entry point |
Gate |
Service call |
PUT /api/trips/:tripId/packing/:id |
TripAccessGuard + packing_edit |
updateItem() |
DELETE /api/trips/:tripId/packing/:id |
TripAccessGuard + packing_edit |
deleteItem() |
POST /api/trips/:tripId/packing/:id/clone |
TripAccessGuard + packing_edit |
cloneItem() via getItemInTrip() |
POST /api/trips/:tripId/packing/save-as-template |
TripAccessGuard + packing_edit |
saveAsTemplate(), which runs SELECT name, category FROM packing_items WHERE trip_id = ? with no visibility filter at all |
POST /api/trips/:id/copy |
trip_create + trip access only, not packing_edit |
TripsService.copy(), which runs SELECT * FROM packing_items WHERE trip_id = ? and re-inserts without is_private or owner_id, so both fall back to the column defaults (0 / NULL) |
| MCP packing tools |
trip access + packing_edit |
same updateItem() / deleteItem() |
| Plugin RPC packing operations |
trip access + packing_edit |
same updateItem() / deleteItem() |
Proof of concept
Two accounts, owner and member, on one shared trip. member is added as an ordinary member.
owner creates a Personal item: POST /api/trips/6/packing with {"name":"Owner private medication","category":"Health","visibility":"personal"} returns is_private=1, owner_id=1.
- Control:
member calls GET /api/trips/6/packing and receives 0 items. The read filter works.
member calls PUT /api/trips/6/packing/1 with {"name":"pwned by member"}. Response HTTP 200, body contains the whole item including "owner_username":"admin".
member calls POST /api/trips/6/packing/1/clone. Response HTTP 201, the clone is created with owner_id=2 (the attacker).
member calls POST /api/trips/6/copy. In the copied trip, GET /api/trips/7/packing returns the item with is_private=0, owner_id=null, i.e. Common and visible to every member of the copy.
Suggested remediation
Centralise object-level authorization in the packing service and make it fail closed:
- A single visibility predicate: Common, or the actor is
owner_id, or the actor has a row in packing_item_recipients. Everything that reads or mutates a single item resolves the item through it.
- Thread the authenticated actor through the HTTP, MCP and plugin-RPC entry points, so no path can call a mutation without one.
- An item the actor may not see must behave exactly like a missing item (404), so the endpoints cannot be used as an existence oracle.
saveAsTemplate and TripsService.copy() must apply the same filter, and the trip copy must not silently drop is_private / owner_id on insert.
Credit
Reported privately by Chichi (@vickyzer027-hash), with a detailed breakdown of the affected paths and a proposed remediation. Reported responsibly, without a public issue, discussion or pull request.
Notes for triage
- The reporter also described gaps in the contributor and shared-recipient constraints. That part has not been independently verified yet and is not covered by the proof of concept above.
- The reporter states they briefly pushed their fix to a branch on a public fork before reading
SECURITY.md, then deleted the branch. Commits pushed to a fork can remain reachable through the fork network after branch deletion, so partial public exposure should be assumed when planning the disclosure timeline.
Fix
Fixed in #1941, shipping with 4.0.0.
One visibility rule in the packing service now backs every single-item lookup, the actor is threaded through the HTTP, MCP and plugin-RPC paths, an item the actor may not see is reported as missing rather than forbidden, and neither the template nor the trip copy carries someone else's restricted item any more.
Verified against a running instance: the update, clone and trip-copy paths from the report all answer 404 or carry nothing over afterwards, while the owner, the recipients and the Common list keep working.
Summary
Packing items in TREK have three visibility tiers: Common (
is_private = 0, everyone on the trip), Personal (is_private = 1, the owner only) and Shared (is_private = 1plus rows inpacking_item_recipients).The read path enforces this.
PackingService.listItems()filters onis_private = 0 OR owner_id = ? OR EXISTS (SELECT 1 FROM packing_item_recipients ...), so a member's own list correctly omits items they may not see.Every other path enforces only trip-level access.
TripAccessGuardplus@RequirePermission('packing_edit')establish that the caller is a member of the trip who may edit packing, and the service methods then load the item scoped totrip_idalone. Knowing (or guessing) an item id is therefore enough for any trip member to reach an item that is not visible to them.Impact
An authenticated user who is already a member of a shared trip can, for packing items belonging to other members:
name,category,quantity,weight_grams,owner_usernameand the recipient list.is_private).Packing-list contents can be sensitive (medication, medical aids, gifts, intimate items), and the owner's identity is disclosed alongside the item.
This is not remotely exploitable by an anonymous user: the attacker must hold an account that is already a member of the trip. Membership in a shared trip is the only privilege required, and the default member role carries
packing_edit.Affected code paths
All of these reach a service method that filters by
trip_idonly:PUT /api/trips/:tripId/packing/:idTripAccessGuard+packing_editupdateItem()DELETE /api/trips/:tripId/packing/:idTripAccessGuard+packing_editdeleteItem()POST /api/trips/:tripId/packing/:id/cloneTripAccessGuard+packing_editcloneItem()viagetItemInTrip()POST /api/trips/:tripId/packing/save-as-templateTripAccessGuard+packing_editsaveAsTemplate(), which runsSELECT name, category FROM packing_items WHERE trip_id = ?with no visibility filter at allPOST /api/trips/:id/copytrip_create+ trip access only, notpacking_editTripsService.copy(), which runsSELECT * FROM packing_items WHERE trip_id = ?and re-inserts withoutis_privateorowner_id, so both fall back to the column defaults (0/NULL)packing_editupdateItem()/deleteItem()packing_editupdateItem()/deleteItem()Proof of concept
Two accounts,
ownerandmember, on one shared trip.memberis added as an ordinary member.ownercreates a Personal item:POST /api/trips/6/packingwith{"name":"Owner private medication","category":"Health","visibility":"personal"}returnsis_private=1, owner_id=1.membercallsGET /api/trips/6/packingand receives 0 items. The read filter works.membercallsPUT /api/trips/6/packing/1with{"name":"pwned by member"}. Response HTTP 200, body contains the whole item including"owner_username":"admin".membercallsPOST /api/trips/6/packing/1/clone. Response HTTP 201, the clone is created withowner_id=2(the attacker).membercallsPOST /api/trips/6/copy. In the copied trip,GET /api/trips/7/packingreturns the item withis_private=0, owner_id=null, i.e. Common and visible to every member of the copy.Suggested remediation
Centralise object-level authorization in the packing service and make it fail closed:
owner_id, or the actor has a row inpacking_item_recipients. Everything that reads or mutates a single item resolves the item through it.saveAsTemplateandTripsService.copy()must apply the same filter, and the trip copy must not silently dropis_private/owner_idon insert.Credit
Reported privately by Chichi (@vickyzer027-hash), with a detailed breakdown of the affected paths and a proposed remediation. Reported responsibly, without a public issue, discussion or pull request.
Notes for triage
SECURITY.md, then deleted the branch. Commits pushed to a fork can remain reachable through the fork network after branch deletion, so partial public exposure should be assumed when planning the disclosure timeline.Fix
Fixed in #1941, shipping with 4.0.0.
One visibility rule in the packing service now backs every single-item lookup, the actor is threaded through the HTTP, MCP and plugin-RPC paths, an item the actor may not see is reported as missing rather than forbidden, and neither the template nor the trip copy carries someone else's restricted item any more.
Verified against a running instance: the update, clone and trip-copy paths from the report all answer 404 or carry nothing over afterwards, while the owner, the recipients and the Common list keep working.