Skip to content

MCP `get_trip_summary` returns trip data + member emails regardless of the token's OAuth scopes (scope-enforcement bypass / PII disclosure)

Moderate
mauriceboe published GHSA-qvw8-w937-vcmq Jul 11, 2026

Software

mauriceboe/TREK

Affected versions

<= 3.2.1

Patched versions

3.3.0

Description


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.tsget_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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

CVE ID

CVE-2026-77321

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits