Summary
TREK exposes an MCP (Model Context Protocol) API whose OAuth 2.1 tokens carry granular per-category scopes (trips:read, places:read, budget:read, weather:read, …). A user authorizing an MCP client picks exactly which scopes to grant, expecting the token to be limited to them.
The get_trip_summary tool is registered without any scope guard and returns the trip's core data — trip (all columns), the member list including every member's and the owner's email address, all days (with notes), and accommodations — to any valid token, regardless of scopes. Only the budget / packing / collab / reservations / todos sub-sections are scope-gated. So a token the user granted only, e.g., weather:read can read the full itinerary and all member emails of every trip that user can access.
The tool itself scope-gates four of its sub-sections, which proves confidentiality-by-scope is the intended model — the core bucket (trip/members/days/accommodations) was simply left ungated. This mirrors the project's own pattern elsewhere and is inconsistent with it.
Root Cause (file:line)
src/mcp/tools/trips.ts — get_trip_summary is registered unconditionally (no if (R) …), unlike every scoped tool around it:
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
server.registerTool('get_trip_summary', { … }, async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess(); // cross-user OK
const summary = getTripSummary(tripId);
…
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const summaryData = {
...summary, // <-- trip, members(email), days, accommodations ALWAYS returned
reservations: canReadRes ? summary.reservations : undefined, // gated
packing: canReadPacking ? summary.packing : undefined, // gated
budget: canReadBudget ? summary.budget : undefined, // gated
collab_notes: canReadCollab ? summary.collab_notes : [], // gated
todos, pollCount, messageCount,
};
return ok(summaryData);
});
src/services/tripService.ts — the always-returned members bucket includes email:
// listMembers()
SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, u.avatar, u.is_guest, …
// owner:
SELECT id, username, email, avatar FROM users WHERE id = ?
// getTripSummary returns: { trip, members:{owner,collaborators}, days, accommodations, budget, packing, reservations, collab_notes }
src/mcp/tools.ts — scopes flow from the token; the tool is registered regardless:
export function registerTools(server, userId, scopes, …) { registerTripTools(server, userId, scopes, …); … }
OAuth MCP tokens carry scopes (a string[]); a static trek_ PAT has null scopes (full access). For any scoped OAuth token, get_trip_summary still registers and returns the core bucket.
Impact
An MCP client the user authorized with a narrow scope (any scope, e.g. weather:read — or even a scope set that excludes trips:read and places:read entirely) can call get_trip_summary(tripId) for every trip the user owns or belongs to and receive:
- full trip metadata (
SELECT * on trips),
- the email address (plus username/avatar) of the owner and every collaborator — third-party PII the token holder was never scoped to read,
- the day-by-day itinerary with notes,
- accommodations.
This breaks the least-privilege promise of the scope consent screen: the user's intent ("this client may only read weather") does not match what the token can actually read. The exposure is limited to trips the token's user can access (cross-user is correctly blocked by canAccessTrip), and requires the user to have issued a scoped MCP token — so this is privilege/consent violation and PII disclosure, not an anonymous leak.
Proof of Concept
Static (confirmed by code): get_trip_summary has no if (R)/scope guard (contrast every other tool in the file); its handler spreads ...summary (trip + members-with-email + days + accommodations) and only conditionally drops reservations/packing/budget/collab_notes. listMembers selects u.email.
Runtime PoC (executed, genuine positive): a test using the project's own MCP harness issues a token scoped to only weather:read, then calls get_trip_summary. File: pocScopeBypassTripSummary.test.ts (archived in this folder).
$ NODE_ENV=test npx vitest run tests/unit/mcp/pocScopeBypassTripSummary.test.ts
stdout | pocScopeBypassTripSummary.test.ts > PoC: MCP get_trip_summary scope bypass
[VULN CONFIRMED] weather:read-only token read get_trip_summary → member emails ["user1@test.example.com","user2@test.example.com"], trip "Confidential Trip"
✓ tests/unit/mcp/pocScopeBypassTripSummary.test.ts (1 test) 292ms
Test Files 1 passed (1)
Tests 1 passed (1)
The token carried scopes: ['weather:read'] (no trips:read, no places:read), yet get_trip_summary returned both members' email addresses and the trip metadata. Harness: createMcpHarness({ userId, scopes: ['weather:read'] }) → client.callTool({ name: 'get_trip_summary', arguments: { tripId } }).
Severity calibration (honest)
- Confirmed (code/L2): the scope guard is absent and the email-bearing member list + itinerary are unconditionally in the payload.
- Requires a scoped MCP token issued by the user (PR:L). Not reachable without the user creating an OAuth MCP client; a static
trek_ PAT is full-access by design (out of scope). Cross-user access is correctly blocked.
C:L reflects one user's trip data + member emails. Raise if member emails across many shared trips are considered higher-impact PII. No integrity/availability impact. Vendor to score (#15).
Pre-armed vendor rebuttal (#28)
- "
list_trips and get_trip_summary are intentional scope-free navigation tools." — Navigation needs trip id + title, which list_trips already provides. get_trip_summary returns member emails, the full itinerary, and accommodations — that is trip content, not navigation. The tool already scope-gates budget/packing/collab/reservations, which shows the intended model is confidentiality-by-scope; the core bucket is an inconsistent gap.
- "The token belongs to the user anyway." — The scope system exists precisely to limit what a delegated MCP client can read on the user's behalf. Granting
weather:read and receiving every trip member's email defeats that purpose.
Fix
Require at least trips:read (and places:read for place/location fields) before returning the core bucket, mirroring the sub-section gating already present:
const canReadTripsCore = canReadTrips(scopes); // already defined in scopes.ts
const summaryData = {
trip: canReadTripsCore ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
members: canReadTripsCore ? summary.members : undefined, // members carry email → gate it
days: canReadTripsCore ? summary.days : undefined,
accommodations: canReadTripsCore ? summary.accommodations : undefined,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos, pollCount, messageCount,
};
Keep list_trips scope-free for navigation (id/title/dates only), but stop returning member emails and full itinerary without a read scope. Alternatively, strip email from the members bucket unless a dedicated scope is present. Add a regression test per scope.
Summary
TREK exposes an MCP (Model Context Protocol) API whose OAuth 2.1 tokens carry granular per-category scopes (
trips:read,places:read,budget:read,weather:read, …). A user authorizing an MCP client picks exactly which scopes to grant, expecting the token to be limited to them.The
get_trip_summarytool is registered without any scope guard and returns the trip's core data —trip(all columns), the member list including every member's and the owner's email address, alldays(with notes), andaccommodations— to any valid token, regardless of scopes. Only thebudget/packing/collab/reservations/todossub-sections are scope-gated. So a token the user granted only, e.g.,weather:readcan read the full itinerary and all member emails of every trip that user can access.The tool itself scope-gates four of its sub-sections, which proves confidentiality-by-scope is the intended model — the core bucket (trip/members/days/accommodations) was simply left ungated. This mirrors the project's own pattern elsewhere and is inconsistent with it.
Root Cause (file:line)
src/mcp/tools/trips.ts—get_trip_summaryis registered unconditionally (noif (R) …), unlike every scoped tool around it:src/services/tripService.ts— the always-returnedmembersbucket includes email:src/mcp/tools.ts— scopes flow from the token; the tool is registered regardless:OAuth MCP tokens carry
scopes(a string[]); a statictrek_PAT hasnullscopes (full access). For any scoped OAuth token,get_trip_summarystill registers and returns the core bucket.Impact
An MCP client the user authorized with a narrow scope (any scope, e.g.
weather:read— or even a scope set that excludestrips:readandplaces:readentirely) can callget_trip_summary(tripId)for every trip the user owns or belongs to and receive:SELECT *ontrips),This breaks the least-privilege promise of the scope consent screen: the user's intent ("this client may only read weather") does not match what the token can actually read. The exposure is limited to trips the token's user can access (cross-user is correctly blocked by
canAccessTrip), and requires the user to have issued a scoped MCP token — so this is privilege/consent violation and PII disclosure, not an anonymous leak.Proof of Concept
Static (confirmed by code):
get_trip_summaryhas noif (R)/scope guard (contrast every other tool in the file); its handler spreads...summary(trip + members-with-email + days + accommodations) and only conditionally dropsreservations/packing/budget/collab_notes.listMembersselectsu.email.Runtime PoC (executed, genuine positive): a test using the project's own MCP harness issues a token scoped to only
weather:read, then callsget_trip_summary. File:pocScopeBypassTripSummary.test.ts(archived in this folder).The token carried
scopes: ['weather:read'](notrips:read, noplaces:read), yetget_trip_summaryreturned both members' email addresses and the trip metadata. Harness:createMcpHarness({ userId, scopes: ['weather:read'] })→client.callTool({ name: 'get_trip_summary', arguments: { tripId } }).Severity calibration (honest)
trek_PAT is full-access by design (out of scope). Cross-user access is correctly blocked.C:Lreflects one user's trip data + member emails. Raise if member emails across many shared trips are considered higher-impact PII. No integrity/availability impact. Vendor to score (#15).Pre-armed vendor rebuttal (#28)
list_tripsandget_trip_summaryare intentional scope-free navigation tools." — Navigation needs trip id + title, whichlist_tripsalready provides.get_trip_summaryreturns member emails, the full itinerary, and accommodations — that is trip content, not navigation. The tool already scope-gatesbudget/packing/collab/reservations, which shows the intended model is confidentiality-by-scope; the core bucket is an inconsistent gap.weather:readand receiving every trip member's email defeats that purpose.Fix
Require at least
trips:read(andplaces:readfor place/location fields) before returning the core bucket, mirroring the sub-section gating already present:Keep
list_tripsscope-free for navigation (id/title/dates only), but stop returning member emails and full itinerary without a read scope. Alternatively, stripemailfrom the members bucket unless a dedicated scope is present. Add a regression test per scope.