Skip to content

MessagingService.listInbox has the same free-userId shape markRead just lost — any in-process plugin can read any user's inbox #11452

Description

@os-sam

Measured while implementing the authenticated-caller scoping for the inbox write door (#10753 / PR #11450). Filing rather than folding in: the ruling on #10753 names markRead and recall, so the read side is outside that card's scope.

The shape

MessagingService.listInbox(userId, opts) (packages/services/service-messaging/src/messaging-service.ts:372) keys its whole read on the caller-supplied userId:

  • where = { user_id: userId } against sys_inbox_message (:378)
  • readReceiptStates(data, userId)where: { user_id: userId, channel: 'inbox' }

On the REST path that parameter is trustworthy — packages/runtime/src/domains/notifications.ts:175 binds it to context.executionContext.userId and answers 401 when there is none. But the service is also registered as a kernel service (ctx.registerService('messaging', service), messaging-service-plugin.ts:162), and the kernel hands every plugin one shared PluginContext (packages/core/src/kernel.ts:65,:91) whose getService carries no caller identity.

So for an in-process caller userId is a free string, and the read lands context-lessly on sys_inbox_message / sys_notification_receipt — the latter managedBy: 'engine-owned' with apiMethods: ['get','list'] (ADR-0103) — so no engine permission check sees it either.

Any plugin can read any user's inbox titles, bodies and read-state. This is the exact shape PR #11450 removed from the write side, on the read side, and it is arguably the more sensitive half: inbox bodies carry rendered business content.

Why it was not fixed in PR #11450

That PR's ruling (comment 5386675655) covers the write verbs only. Widening it to the read side without a ruling would be scope creep on a card whose whole point was that public-surface widening is the maintainer's call.

Suggested shape (not a decision)

The same one PR #11450 landed: a plugin-facing listInboxAsCaller(caller: ExecutionContext, opts) that derives the recipient from caller.userId and refuses with InboxCallerError (401 UNAUTHENTICATED) otherwise, reusing resolveInboxRecipient from packages/services/service-messaging/src/inbox-caller.ts. listInbox(userId, opts) stays as the published INotificationService contract the REST door needs.

Note the same honest caveat that PR applies to itself: this is a discipline boundary, not a security boundary — an in-process plugin already holds the data engine.

Links

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions