// Security Assessment

Robin Enterprise

Robin
2026-09-04 – 2026-09-04

Summary#

About this report

Assessment type
Security Assessment (source review)
Date of engagement
2026-09-04 – 2026-09-04
Source code
robin
Assessed commit ID
e0326609

Severity breakdown

24
All findings
0 solved
0
Critical
Solved 0
1
High
Solved 0
0
Medium
Solved 0
3
Low
Solved 0
20
Informational
Solved 0

0 acknowledged · 0 risk accepted

Introduction#

Robin engaged Tecmie to conduct a security assessment of Robin Enterprise, beginning on 2026-09-04 and ending on 2026-09-04. The assessment was scoped to the server-side application, workspace packages, and configuration of the Robin Enterprise monorepo at the assessed commit. Commit hashes, assessed components, and further details can be found in the Scope section of this report.

Robin Enterprise is a multi-user, workspace-partitioned "second brain". Members capture free-form thoughts through a Model Context Protocol endpoint (Claude Desktop, ChatGPT and similar clients) or the web app; a six-stage AI pipeline splits each capture into atomic fragments, classifies them into topic wikis with a large language model, regenerates the wiki prose with citations, and indexes everything for hybrid keyword-and-vector search. Everything a member writes is stored as text rows in PostgreSQL and shared across their organization’s workspaces, governed by an owner/admin/member role model, per-wiki guardian grants, and a CASL-derived permission engine. The server is also an OAuth 2.1 authorization server and issues the tokens its own MCP clients use.

Tecmie assigned a security engineer to review the source code. The review was conducted as a static, adversarial source review of the repository at the assessed commit; runtime infrastructure, production configuration, and organisational processes were out of scope unless evidenced in the repository.

Assessment Summary#

The purpose of this assessment is to:

In summary, Tecmie identified the following improvements to reduce the likelihood and impact of risks. The main ones were:

Test Approach and Methodology#

Tecmie performed a combination of manual and automated review to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. Manual review was emphasised to uncover flaws in logic, authorisation, and component interaction, while automated tools supported the detection of dependency vulnerabilities and unsafe coding patterns.

The following phases were used during the assessment:

Risk Methodology#

Every vulnerability and issue observed is ranked based on two sets of metrics and a severity coefficient. This system is inspired by the industry-standard Common Vulnerability Scoring System.

The two metric sets are Exploitability and Impact. Exploitability captures the ease and technical means by which a vulnerability can be exploited; Impact describes the consequences of a successful exploit. The severity coefficient refines the ranking with two factors, Reversibility and Scope, which capture how far the effects can be undone and whether they cross a tenant or system boundary.

The final score is a value between 0 and 10, rounded up to one decimal place, with 10 corresponding to the highest security risk.

5.1 Exploitability

Attack Origin (AO): whether the attack requires a specific privileged account (Specific) or can be mounted by any unauthenticated or self-registered user (Arbitrary).
Attack Cost (AC): the cost to the attacker relative to a single ordinary request, including financial and computational cost.
Attack Complexity (AX): conditions beyond the attacker's control that must exist to exploit the vulnerability.

Exploitability metric (me)Metric valueNumerical value
Attack Origin (AO)Arbitrary (AO:A)1
Specific (AO:S)0.2
Attack Cost (AC)Low (AC:L)1
Medium (AC:M)0.67
High (AC:H)0.33
Attack Complexity (AX)Low (AX:L)1
Medium (AX:M)0.67
High (AX:H)0.33

Exploitability E is the product of all exploitability metric values: E = ∏ me

5.2 Impact

Confidentiality (C): impact to the confidentiality of data managed by the system, including cross-tenant disclosure.
Integrity (I): impact to the trustworthiness of stored or processed data, including model-generated content persisted as fact.
Availability (A): impact to the availability of features and functionality.
Privilege (P): degree of unauthorised permission gained, from none to full administrative control.

Impact metric (mi)Metric valueNumerical value
Confidentiality (C)None (C:N)0
Low (C:L)0.25
Medium (C:M)0.5
High (C:H)0.75
Critical (C:C)1
Integrity (I)None … Critical (I:N … I:C)0 … 1
Availability (A)None … Critical (A:N … A:C)0 … 1
Privilege (P)None … Critical (P:N … P:C)0 … 1

Impact I is calculated as: I = max(mi) + (∑ mi − max(mi)) / 4

5.3 Severity coefficient

Reversibility (R): the share of the exploit's effects that can be reversed by the operator.
Scope (S): whether the vulnerability in one component or tenant affects resources in another.

Severity coefficient (C)Coefficient valueNumerical value
Reversibility (R)None (R:N)1
Partial (R:P)0.5
Full (R:F)0.25
Scope (S)Changed (S:C)1.25
Unchanged (S:U)1

Severity coefficient: C = R × S. Vulnerability severity score: S = min(10, E × I × C × 10), rounded up to one decimal place.

CriticalHighMediumLowInformational
9 – 107 – 8.94.5 – 6.92 – 4.40 – 1.9

Scope#

Repository

(a) Repository: robin (local checkout ./source · github.com/withrobinhq/robin-enterprise)
(b) Assessed commit ID: e0326609ec7b4bcfddf8f59d7a6a53a4664816c3
(c) Branch: main

Items in scope

  • server/src513 files
    • modules/ (admin, iam, entries, signals, wikis, capture, users, workspaces, …)164 files
    • core/ (authz, keypair, oauth, guardianship, regen)45 files
    • mcp/33 files
    • queue/29 files
    • routes/26 files
    • lib/83 files
    • middleware/2 files
  • packages/ (permissions 16, oauth 13, auth 11, caslock 11, queue 8, agent 42, shared 81, …)198 files
  • app/src447 files — frontend, partial (see Test Plan coverage gap)

Out of scope: the Next.js frontend (app/, examined only at the published-wiki render sink — see the Test Plan coverage gap), third-party dependencies beyond version and reachability review, production infrastructure and runtime configuration, the hosted multi-tenant provisioning path (not present in this repository), and organisational processes. No live database, Redis or model endpoint was available; every exploit is a source trace or, where noted, a trace against the locked dependency versions.

Remediation commit ID

Pending

Assessment Summary & Findings Overview#

#TitleSeverityScoreStatus
TEC-001 Anonymous pre-registration of an invited email seizes the account, with no proof of mailbox control High 7.6 Open
TEC-003 GET /admin/diagnose/:entryKey returns any entry and its signals with no workspace scope Low 2.1 Open
TEC-015 Client IP is taken from the left-most X-Forwarded-For entry, so any caller can forge it Low 2.1 Open
TEC-023 Chain: an org admin enumerates then reads private-workspace entry content Low 2.1 Open
TEC-017 GET /entries/:id/signals lists another workspace’s signals with no scope check Informational 1.3 Open
TEC-022 Chain: a member harvests foreign entry keys then enumerates their signals across workspaces Informational 1.3 Open
TEC-012 Signal-relationship backfill worker links signals across workspace boundaries Informational 1.1 Open
TEC-004 GET /admin/graph/stats exposes instance-wide aggregates to any authenticated user Informational 0.7 Open
TEC-018 /settings/spend endpoints return instance-wide usage_events to any member Informational 0.7 Open
TEC-020 Unescaped organization name injects HTML and subject content into invite and reset emails Informational 0.7 Open
TEC-021 Chain: an application-log reader gains MCP write authority as a member Informational 0.7 Open
TEC-024 Chain: an org admin sends attacker-authored mail from the platform’s verified sender Informational 0.7 Open
TEC-006 MCP write authority rests on a 4-character passcode with no rate limit, no scope, and a resolution oracle Informational 0.6 Open
TEC-005 POST /admin/retry-stuck enumerates and re-enqueues PENDING signals across every workspace Informational 0.5 Open
TEC-008 MCP set_auto_accept_persons flips an instance-wide setting behind a weaker gate than the HTTP route Informational 0.5 Open
TEC-009 MCP write-tool invocations emit actorless audit rows; denied writes emit none Informational 0.5 Open
TEC-010 Pipeline classifier’s model-emitted domainId is written cross-workspace without a containment check Informational 0.5 Open
TEC-014 Bulk knowledge-base export and self-service account erasure write no audit record Informational 0.5 Open
TEC-019 Native better-auth organization endpoints bypass the app’s org-management controls and audit Informational 0.5 Open
TEC-007 MCP confused deputy: write authorized as the passcode user but executed and attributed as the transport user Informational 0.4 Open
TEC-013 Capture-upload create-gate authorizes against a caller-asserted org label, not the target workspace’s real org Informational 0.4 Open
TEC-002 Member/invitation passcode written to application logs in cleartext Informational 0.3 Open
TEC-011 Handlebars validation allowlist is bypassed for structure fields compiled as sub-templates at regen Informational 0.2 Open
TEC-016 Branch-protection rulesets require a status check no workflow emits and omit the test jobs Informational 0.1 Open

Findings & Tech Details#

TEC-001
Open
Anonymous pre-registration of an invited email seizes the account, with no proof of mailbox control
High · 7.6
AO:A/AC:L/AX:M/R:N/S:U/C:H/I:M/A:L/P:H

Description

Sign-up is closed once the instance has its first user, but the before hook on /api/auth/sign-up/email re-opens it for any email that has a pending invitation, matched by address alone. No mailbox proof is required: email verification is switched off across the app (requireEmailVerificationOnInvitation: false, and no sendVerificationEmail/requireEmailVerification anywhere), a design trade-off the code itself flags as re-opening better-auth advisory GHSA-fmh4-wcc4-5jm3.

An attacker who knows or predicts an invitee's address (a named hire, a partner, an address learned from the anonymous GET /org/invitation/:id lookup) therefore creates the account first, under a password they choose. The real invitee can no longer sign up — the email is taken — so their onboarding is denied, and when they accept the invite the attacker's session becomes a full member session at the invited role. Because the app sets neither autoSignIn: false nor revokeSessionsOnPasswordReset (emailAndPassword at auth.ts:181–186), better-auth's defaults leave the attacker signed in and do not revoke that session when the owner later resets the password, so the takeover outlives the reclaim.

The scope stays inside the one organization (the attacker seizes an identity that was going to join it anyway), which is why the metric vector marks Scope Unchanged; the impact is the seized identity's confidentiality, the ability to write as them, and the denial of the legitimate owner's onboarding.

Code location

The before hook for /sign-up/email from server/src/modules/iam/auth.ts, lines 414–440:
      if (ctx.path === '/sign-up/email') {
        const [row] = await db.execute<{ count: number }>(
          sql`SELECT count(*)::int AS count FROM users`
        )
        if (row && row.count > 0) {
          // Allow signup if a valid pending invitation exists for this email
          const email = (ctx.body as Record<string, unknown> | undefined)?.email as
            | string
            | undefined
          if (email) {
            const [invitation] = await db
              .select({ id: schema.invitations.id })
              .from(schema.invitations)
              .where(
                and(
                  eq(schema.invitations.email, email),
                  eq(schema.invitations.status, 'pending'),
                  gt(schema.invitations.expiresAt, new Date())
                )
              )
              .limit(1)
            if (invitation) return
          }
          throw new APIError('FORBIDDEN', {
            message: 'sign-ups disabled — single-user mode',
          })
        }

Proof of concept

Scenario. An admin invites alice@corp.example. Before Alice signs up, the attacker sends POST /api/auth/sign-up/email for that address. The gate finds the pending invitation and returns (allow); better-auth creates the users row under the attacker's password and issues a session. Alice's later sign-up fails on the duplicate email; if the attacker accepts the invitation, afterAcceptInvitation stamps a members row at the invited role. Alice resetting her password does not revoke the attacker's existing session.

Test.

# 1. (optional) confirm a pending invite exists for the target — anonymous oracle
curl https://HOST/org/invitation/<inviteId>      # → {email, organizationName, hasExistingAccount:false}

# 2. pre-register the invited address under an attacker password
curl -X POST https://HOST/api/auth/sign-up/email \
  -H 'content-type: application/json' \
  -d '{"email":"alice@corp.example","password":"Attacker#1","name":"x"}'
# → 200 + Set-Cookie session  (gate bypassed: pending invite matched by email only)

# 3. the real Alice can no longer register (duplicate email); the attacker
#    accepts the invite and holds a member session at Alice's invited role.

Recommendation

Require possession of the out-of-band invitation passcode (already stored on the invitation row) in the sign-up body for any address with a pending invite, and reject sign-up without it; or add a real verify-on-accept flow. Additionally set emailAndPassword.revokeSessionsOnPasswordReset: true (or delete the user's sessions in afterAcceptInvitation) so a reclaimed account cannot keep an attacker's session alive.

Remediation comment

Pending.

TEC-003
Open
GET /admin/diagnose/:entryKey returns any entry and its signals with no workspace scope
Low · 2.1
AO:S/AC:L/AX:L/R:N/S:C/C:H/I:N/A:N/P:L

Description

The diagnostics route is gated to org administrators (requireAbility('manage','OrgSettings') on the /admin router), but its handler looks an entry up by lookup_key alone and returns the full entry row, its pipeline events, audit events, usage events, and a projected signal list — with no workspace-membership or accessibleByDrizzle filter.

Captured entries live in per-workspace private spaces; the permission engine deliberately denies an org admin read access to the content of a private workspace they are not a member of (that is the invariant accessibleByDrizzle('read','Entry') enforces on the normal GET /entries/:id route directly beside this one). This route ignores that invariant, so any org admin reads any workspace's raw captured text by entry key. The vector marks Scope Changed because the read crosses a workspace tenancy boundary the caller is not entitled to.

Under a hosted multi-organization deployment where an attacker can self-register an organization, the origin becomes Arbitrary and this finding scores 10.0 Critical.

Code location

The /diagnose/:entryKey lookup in adminRoutes from server/src/modules/admin/routes/admin.ts, lines 120–126:
adminRoutes.get('/diagnose/:entryKey', async (c) => {
  const entryKey = c.req.param('entryKey')
  if (!entryKey) {
    return c.json({ error: 'entryKey required' }, 400)
  }

  const [entryRow] = await db.select().from(entries).where(eq(entries.lookupKey, entryKey)).limit(1)

Proof of concept

Scenario. An org admin who is not a member of the private "Board" workspace calls GET /admin/diagnose/<anyEntryKey> and receives that entry's full content plus its signals — content the ability engine would deny on GET /entries/<key>. Entry keys are enumerable via TEC-005's dry-run and TEC-018's spend feed (see chain TEC-023).

Test.

# as an org_admin who is NOT a member of the target workspace:
curl -H "cookie: <admin session>" https://HOST/admin/diagnose/<foreignEntryKey>
# → 200 { entry: { ...full content... }, signals:[...], auditEvents:[...] }
# contrast: GET /entries/<foreignEntryKey> → 404 (accessibleByDrizzle denies)

Recommendation

Fetch the entry with accessibleByDrizzle(c.get('ability'),'read','Entry') in the WHERE (as GET /entries/:id does) and filter the signal list with accessibleByDrizzle(..., 'read','Signal'), so the diagnostic view cannot exceed the caller's real read scope.

Remediation comment

Pending.

TEC-015
Open
Client IP is taken from the left-most X-Forwarded-For entry, so any caller can forge it
Low · 2.1
AO:A/AC:L/AX:M/R:P/S:U/C:N/I:M/A:M/P:N

Description

getClientIp returns the first comma-separated token of the X-Forwarded-For header with no trusted-proxy hop count. Every rate-limit key (/auth/recover, OAuth dynamic client registration) and every recovery audit record is keyed on that value, so an anonymous caller who sets the header chooses their own identity.

The consequence is twofold. An attacker rotates a spoofed X-Forwarded-For per request to get a fresh rate-limit bucket, defeating the IP throttles that protect /auth/recover and registration; and they write attacker-controlled strings into the durable audit_log recovery rows, poisoning the security trail. Better-auth's own sign-in limiter reads the same header with no trusted-proxy configuration, so behind an appending proxy an attacker who adds one header entry collapses every caller into a single shared bucket and can lock all users out of sign-in.

Code location

getClientIp from server/src/lib/getClientIp.ts, lines 14–18:
export function getClientIp(c: Context): string {
  return (
    c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? 'unknown'
  )
}

Proof of concept

Scenario. Attacker sends repeated POST /auth/recover or /api/auth/sign-in/email requests, each with a different X-Forwarded-For: 1.2.3.<n>. Each lands in its own rate-limit bucket, so the per-IP throttle never trips; the recovery audit rows record the forged IPs.

Test.

for i in $(seq 1 100); do
  curl -s -X POST https://HOST/auth/recover \
    -H "x-forwarded-for: 10.0.0.$i" \
    -H 'content-type: application/json' \
    -d '{"secretKey":"guess","newPassword":"Aa1aaaaa"}' >/dev/null
done
# each request is a distinct bucket → the 5/min, 60/day IP limit never applies

Recommendation

Derive the client IP from a configured trusted-proxy hop count (e.g. take the right-most untrusted entry, or the Nth-from-last given a known proxy depth), and set better-auth's advanced.ipAddress.trustedProxies. Add a per-account sign-in lockout backed by shared (Redis) state rather than the per-process memory limiter.

Remediation comment

Pending.

TEC-023
Open
Chain: an org admin enumerates then reads private-workspace entry content
Low · 2.1
AO:S/AC:L/AX:L/R:N/S:C/C:H/I:N/A:N/P:L

Description

This is the end-to-end outcome of combining TEC-005 and TEC-003. Neither route scopes to workspace membership: POST /admin/retry-stuck?dryRun=true returns signal and entry identifiers across every workspace, and GET /admin/diagnose/:entryKey returns the full content of any entry by key.

An org administrator who is not a member of a private workspace uses the first route to harvest that workspace's entry keys, then the second to read each entry's captured content — the exact cross-workspace disclosure the permission engine denies on the member-facing routes. Scored as one outcome at the combined impact (high-confidentiality cross-workspace read of captured thoughts).

In a hosted multi-organization model with self-service org creation this chain scores 10.0 Critical.

Code location

The unscoped enumeration in /retry-stuck (constituent of the chain) from server/src/modules/admin/routes/admin.ts, lines 53–60:
  const stuckSignals = (await db.execute(
    sql`SELECT s.lookup_key, s.entry_id, e.content
        FROM ${signals} s
        JOIN ${entries} e ON e.lookup_key = s.entry_id
        WHERE s.state = 'PENDING'
          AND s.locked_by IS NULL
          AND s.updated_at < NOW() - make_interval(mins => ${minutes})
        ORDER BY s.updated_at ASC`

Proof of concept

Scenario. Org admin → POST /admin/retry-stuck?dryRun=true&minutes=1 returns {signals:[{entryKey,...}]} spanning all workspaces → for each foreign entryKey, GET /admin/diagnose/<entryKey> returns the entry's full content.

Test.

curl -H "cookie: <admin>" -X POST 'https://HOST/admin/retry-stuck?dryRun=true&minutes=1'   # → entry/signal keys, all workspaces
curl -H "cookie: <admin>" https://HOST/admin/diagnose/<foreignEntryKey>                       # → full private content

Recommendation

Scope both routes to the caller's readable workspaces (accessibleByDrizzle); see the fixes in TEC-003 and TEC-005.

Remediation comment

Pending.

TEC-017
Open
GET /entries/:id/signals lists another workspace’s signals with no scope check
Informational · 1.3
AO:S/AC:L/AX:L/R:N/S:C/C:M/I:N/A:N/P:N

Description

Unlike the sibling GET /entries/:id, which filters with accessibleByDrizzle('read','Entry'), the /:id/signals handler resolves the entry by lookup_key and returns every signal with that entryId — no requireAbility, no accessibleByDrizzle, no workspace predicate. Any authenticated member reads another workspace's signal names, slugs, tags, kind, state and confidence by entry key (bodies are stripped by the response schema).

Hosted multi-org alternative: 6.3 Medium.

Code location

GET /entries/:id/signals from server/src/modules/entries/routes.ts, lines 313–331:
// GET /entries/:id/signals — get all signals derived from an entry
entries.get('/:id/signals', async (c) => {
  const id = c.req.param('id')

  const [entry] = await db
    .select()
    .from(entriesTable)
    .where(and(eq(entriesTable.lookupKey, id), isNull(entriesTable.deletedAt)))
  if (!entry) return c.json({ error: 'Not found' }, 404)

  const rows = await db
    .select()
    .from(signals)
    .where(and(eq(signals.entryId, id), isNull(signals.deletedAt)))
    .orderBy(desc(signals.createdAt))

  // This route is deliberately unpaginated — an entry's signal fan-out is
  // small and bounded by extraction. So the shared `meta` is exact and can
  // never be truncated; it is filled in rather than omitted so every

Recommendation

Add requireAbility('read','Entry') and scope both selects with accessibleByDrizzle, matching GET /entries/:id.

Remediation comment

Pending.

TEC-022
Open
Chain: a member harvests foreign entry keys then enumerates their signals across workspaces
Informational · 1.3
AO:S/AC:L/AX:L/R:N/S:C/C:M/I:N/A:N/P:N

Description

Combining TEC-018 and TEC-017: any member pulls up to 200 recent usage_events rows from GET /settings/spend/recent — which carry entryKey, wikiKey, signalKey and the userId that captured them, for every workspace — then feeds each foreign entryKey into the unscoped GET /entries/:id/signals. The result is cross-workspace enumeration of what other members captured and how it was structured, from a plain member account.

Hosted multi-org alternative: 6.3 Medium.

Code location

The unscoped usage_events read that feeds the chain from server/src/routes/settings.ts, lines 176–184:
    .select()
    .from(usageEvents)
    .where(gte(usageEvents.createdAt, since))
    .orderBy(sql`${usageEvents.createdAt} DESC`)
    .limit(limit)
  return c.json({
    items: rows.map((r) => ({
      ...r,
      createdAt: r.createdAt.toISOString(),

Recommendation

Fixing either constituent breaks the chain: scope /settings/spend/recent to the caller's readable workspaces (TEC-018) and scope /entries/:id/signals (TEC-017).

Remediation comment

Pending.

TEC-012
Open
Signal-relationship backfill worker links signals across workspace boundaries
Informational · 1.1
AO:S/AC:L/AX:M/R:N/S:C/C:M/I:M/A:N/P:N

Description

The nightly backfill finds each signal's nearest neighbours purely by embedding distance, with no workspace predicate, and writes SIGNAL_RELATED_TO_SIGNAL edges between them. GET /signals/:id then resolves those edges and returns the related signals' slugs and names with no accessibleByDrizzle or edges.workspaceId filter (unlike the MCP resolver and regen paths, which do scope). A member viewing their own signal is shown the titles of semantically similar signals from other workspaces; no attacker is required — the cron creates the linkage.

Hosted multi-org alternative: 5.3 Medium.

Code location

The neighbour query (no workspace predicate) from server/src/queue/signal-relationship-backfill-worker.ts, lines 212–227:

  const neighbours = await db
    .select({
      lookupKey: signals.lookupKey,
      distance: sql<number>`${signals.embedding} <=> ${vecLiteral}::vector`,
    })
    .from(signals)
    .where(
      and(
        isNull(signals.deletedAt),
        sql`${signals.embedding} IS NOT NULL`,
        sql`${signals.lookupKey} != ${signalKey}`,
        sql`${signals.embedding} <=> ${vecLiteral}::vector < ${maxDistance}`
      )
    )
    .orderBy(sql`${signals.embedding} <=> ${vecLiteral}::vector`)

Recommendation

Constrain the neighbour query to the source signal's workspace, and filter the related-signal read in GET /signals/:id with accessibleByDrizzle('read','Signal').

Remediation comment

Pending.

TEC-004
Open
GET /admin/graph/stats exposes instance-wide aggregates to any authenticated user
Informational · 0.7
AO:S/AC:L/AX:L/R:N/S:C/C:L/I:N/A:N/P:N

Description

The graph-stats router applies only sessionMiddleware — no resolveOrgContext, no attachAbility, no role gate. Because it is mounted at /admin/graph ahead of the /admin router, the manage OrgSettings middleware that guards the other admin routes never runs for it. Any authenticated user reads instance-wide people/wiki/signal counts. (GET /users/settings/outstanding shares this session-only shape.)

Hosted multi-org alternative: 3.2 Low.

Code location

adminGraphStatsRoutes middleware from server/src/modules/admin/routes/graph-stats.ts, lines 31–33:

export const adminGraphStatsRoutes = new Hono()
adminGraphStatsRoutes.use('*', sessionMiddleware)

Recommendation

Apply resolveOrgContext, attachAbility and requireAbility('manage','OrgSettings') to adminGraphStatsRoutes (and to /users/settings/outstanding), and scope the aggregates to the caller's org.

Remediation comment

Pending.

TEC-018
Open
/settings/spend endpoints return instance-wide usage_events to any member
Informational · 0.7
AO:S/AC:L/AX:L/R:N/S:C/C:L/I:N/A:N/P:N

Description

GET /settings/spend/recent returns up to 200 raw usage_events rows with no tenant predicate — the row is spread verbatim into the response, exposing entryKey, wikiKey, signalKey, userId, model, provider, cost and metadata for every workspace. GET /settings/spend aggregates the same table instance-wide, and PUT /settings/budgets/:kind writes org-less app_settings rows. This is the identifier source that makes the TEC-022 chain work.

Hosted multi-org alternative: 3.2 Low.

Code location

GET /settings/spend/recent from server/src/routes/settings.ts, lines 176–186:
    .select()
    .from(usageEvents)
    .where(gte(usageEvents.createdAt, since))
    .orderBy(sql`${usageEvents.createdAt} DESC`)
    .limit(limit)
  return c.json({
    items: rows.map((r) => ({
      ...r,
      createdAt: r.createdAt.toISOString(),
    })),
  })

Recommendation

Stamp organization_id/workspace_id on usage_events and filter by the caller's readable workspaces, or gate the spend reads behind manage OrgSettings.

Remediation comment

Pending.

TEC-020
Open
Unescaped organization name injects HTML and subject content into invite and reset emails
Informational · 0.7
AO:S/AC:L/AX:L/R:P/S:C/C:N/I:M/A:N/P:N

Description

The email module defines an esc() helper but applies it only to guardian-notice fields. The organization name is interpolated raw into the invitation email subject and body and the passcode-reset email subject and body. An org admin can rename the organization to arbitrary markup (native organization.update validates nothing) and invite arbitrary external addresses, so recipients receive attacker-authored HTML from the platform's verified sender — a phishing primitive from a trusted domain.

Code location

The invite email subject from server/src/lib/email.ts, lines 47–52:
  const { to, inviteUrl, orgName } = opts

  const { data, error } = await getResend().emails.send({
    from: getFromAddress(),
    to,
    subject: `You've been invited to ${orgName} on Robin`,

Recommendation

Escape the organization name (and every interpolated field) in all email templates and plain-text subjects, and restrict /org/invite recipients.

Remediation comment

Pending.

TEC-021
Open
Chain: an application-log reader gains MCP write authority as a member
Informational · 0.7
AO:S/AC:L/AX:M/R:P/S:U/C:N/I:H/A:N/P:H

Description

Combining TEC-002, TEC-006/TEC-007 and TEC-009. The native invitation hook logs the invitee's 4-character passcode at INFO; that passcode is copied to the members row and is the MCP write-authorization credential. Anyone able to read application logs (ops, a log aggregator, or a separate log-exposure) supplies it as the passcode tool argument on any MCP transport they hold and performs writes authorized as that member — and the resulting audit_log row records actorType 'system' with no user. Breaking any single leg (stop logging the passcode, bind write authorization to the transport identity, or audit passcode resolution) breaks the chain.

Code location

The invitation hook logging the live passcode (constituent) from server/src/modules/iam/auth.ts, lines 239–246:
          .update(schema.invitations)
          .set({ passcode })
          .where(eq(schema.invitations.id, data.invitation.id))

        log.info(
          { email: data.email, inviteUrl, passcode, org: data.organization.name },
          'invitation created — share this URL and passcode with the invitee'
        )

Recommendation

Remove the passcode from the log line (or redact it centrally), and bind MCP write authorization to the transport-authenticated identity rather than a resolvable passcode.

Remediation comment

Pending.

TEC-024
Open
Chain: an org admin sends attacker-authored mail from the platform’s verified sender
Informational · 0.7
AO:S/AC:L/AX:L/R:P/S:C/C:N/I:M/A:N/P:N

Description

Combining TEC-019 and TEC-020 with /org/invite. The native organization.update endpoint accepts an unvalidated organization name; that name is rendered raw in invite/reset emails; and /org/invite accepts any recipient address. An org admin renames the organization to a phishing payload and invites external targets, who receive the payload as mail from the Resend-verified sender.

Code location

The passcode-reset subject/body (raw org name) from server/src/lib/email.ts, lines 132–140:
  orgName: string
}): Promise<void> {
  const { to, passcode, orgName } = opts

  const { data, error } = await getResend().emails.send({
    from: getFromAddress(),
    to,
    subject: `Your Robin passcode for ${orgName} was reset`,
    html: `

Recommendation

Escape interpolated fields in every template (TEC-020), validate the organization name on update, and restrict invitation recipients.

Remediation comment

Pending.

TEC-006
Open
MCP write authority rests on a 4-character passcode with no rate limit, no scope, and a resolution oracle
Informational · 0.6
AO:S/AC:M/AX:M/R:P/S:C/C:M/I:H/A:N/P:H

Description

On the legacy MCP transport, the identity whose ability authorizes a write is resolved from a passcode — either a URL parameter or a tool argument — looked up globally as members WHERE passcode = code. Passcodes are four characters over a 36-symbol alphabet (about 1.68 million values), are shared out of band, and are shown on member profiles and the admin dashboard. The /mcp route applies no per-call rate limit, and the tool gate returns a distinct "target could not be found" message versus a permission denial, giving a resolution oracle. A holder of any valid legacy transport credential can therefore brute-force or reuse another member's passcode to write with that member's ability; the highest-value targets are workspace-admin and guardian passcodes (organization roles carry no wiki verbs).

If any member counts as an Arbitrary origin (open/shared MCP URLs), this scores 3.0 Low.

Code location

generatePasscode from server/src/lib/passcode.ts, lines 10–14:
// Digits + uppercase only: passcodes are shared out-of-band (read aloud,
// typed from an email), so no lowercase and none of default-nanoid's -/_.
// Existing stored passcodes drawn from the old alphabet stay valid; only new
// draws use this one.
export const generatePasscode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 4)

Recommendation

Replace the 4-character passcode with a high-entropy per-member secret, rate-limit passcode resolution on /mcp, make resolution failures indistinguishable from permission denials, and bind write authorization to the transport identity rather than a resolvable code.

Remediation comment

Pending.

TEC-005
Open
POST /admin/retry-stuck enumerates and re-enqueues PENDING signals across every workspace
Informational · 0.5
AO:S/AC:L/AX:L/R:P/S:C/C:L/I:L/A:L/P:N

Description

The stuck-signal recovery route selects every PENDING, unlocked signal across all workspaces with no tenant predicate and (outside dry-run) re-enqueues a link job for each. The re-enqueue is idempotent reprocessing the pipeline would perform anyway, so no privilege is gained; the security-relevant part is the dryRun=true response, which returns signal and entry identifiers spanning every workspace — the enumeration primitive for the TEC-023 chain.

Hosted multi-org alternative: 2.4 Low.

Code location

The unscoped stuck-signal query from server/src/modules/admin/routes/admin.ts, lines 53–60:
  const stuckSignals = (await db.execute(
    sql`SELECT s.lookup_key, s.entry_id, e.content
        FROM ${signals} s
        JOIN ${entries} e ON e.lookup_key = s.entry_id
        WHERE s.state = 'PENDING'
          AND s.locked_by IS NULL
          AND s.updated_at < NOW() - make_interval(mins => ${minutes})
        ORDER BY s.updated_at ASC`

Recommendation

Scope the query to the caller's org/workspaces, and omit cross-workspace identifiers from the dry-run response.

Remediation comment

Pending.

TEC-008
Open
MCP set_auto_accept_persons flips an instance-wide setting behind a weaker gate than the HTTP route
Informational · 0.5
AO:S/AC:L/AX:M/R:P/S:C/C:N/I:M/A:N/P:L

Description

The set_auto_accept_persons tool is gated as manage Person, which a workspace administrator holds, and it upserts a global app_settings row (no workspace column) that governs person auto-verification for the whole instance. The equivalent HTTP route (POST /admin/settings/auto-accept-persons) is reserved to manage OrgSettings (org admin and above). The MCP surface therefore lets a lower-privileged principal change an instance-wide setting the HTTP surface protects more strictly.

Hosted multi-org alternative: 2.4 Low.

Code location

The tool-permission mapping from server/src/mcp/tool-permissions.ts, lines 397–399:
  // Instance-wide setting (app_settings, no workspace column) — restricted to
  // workspace_admin+ of root or an org-wide admin, never a plain member.
  set_auto_accept_persons: { action: 'manage', subject: 'Person' },

Recommendation

Gate set_auto_accept_persons as manage OrgSettings to match the HTTP route, and scope the setting per workspace/org.

Remediation comment

Pending.

TEC-009
Open
MCP write-tool invocations emit actorless audit rows; denied writes emit none
Informational · 0.5
AO:S/AC:L/AX:L/R:N/S:U/C:N/I:L/A:N/P:N

Description

emitAuditEvent defaults actorType to 'system' and actorUserId to null, and the MCP write handlers call it without supplying either. So an MCP-driven write is recorded with no acting user, no organization and no workspace; a denied write logs only to the application logger, never to audit_log. MCP writes are therefore not attributable, and passcode brute-forcing (TEC-006) leaves no durable trail.

Code location

The actor defaults in emitAuditEvent from server/src/modules/audit/audit.ts, lines 54–56:
      workspaceId: params.workspaceId ?? null,
      actorType: params.actorType ?? 'system',
      actorUserId: params.actorUserId ?? null,

Recommendation

Pass the resolved acting user, organization and workspace into every MCP emitAuditEvent call, and emit an audit row on denied writes.

Remediation comment

Pending.

TEC-010
Open
Pipeline classifier’s model-emitted domainId is written cross-workspace without a containment check
Informational · 0.5
AO:S/AC:L/AX:M/R:P/S:C/C:L/I:M/A:N/P:N

Description

When the pipeline attaches a signal to a domain chosen by the LLM classifier, insertDomainSignalRow re-checks only that the target domain is still live (not soft-deleted) — it does not check that the domain and the signal belong to the same workspace. A model-emitted domainId therefore links a signal into another workspace's domain, and get_domain_signals/get_domain_graph surface those signals (including a content slice) with no per-signal workspace filter. Model output crosses a tenant boundary as a write key with no containment.

Hosted multi-org alternative: 2.4 Low.

Code location

The liveness-only guard in insertDomainSignalRow from server/src/queue/worker.ts, lines 295–306:
  const [stillLive] = await db
    .select({ id: domains.id })
    .from(domains)
    .where(and(eq(domains.id, row.domainId), isNull(domains.deletedAt)))
    .limit(1)
  if (!stillLive) {
    log.warn(
      { domainId: row.domainId, signalId: row.signalId },
      'skipping domain_signals insert: target domain was soft-deleted'
    )
    return
  }

Recommendation

Constrain the classifier's candidate domains to the signal's workspace and re-check domain.workspaceId === signal.workspaceId before insert; filter the domain-signal read paths by workspace.

Remediation comment

Pending.

TEC-014
Open
Bulk knowledge-base export and self-service account erasure write no audit record
Informational · 0.5
AO:S/AC:L/AX:L/R:N/S:U/C:N/I:L/A:N/P:N

Description

The bulk export (POST /users/export) and the account-erasure handler (DELETE /users/account) emit no audit_log record, though sibling routes in the same file do. Export is scoped to the caller's own readable set (not a disclosure), but a member exfiltrating everything they can read, or erasing their account (which hard-deletes the users row and can reopen sign-up), leaves no durable security-relevant trail.

Code location

The export handler (no emitAuditEvent in its body) from server/src/modules/users/routes.ts, lines 430–436:
usersRouter.post('/export', resolveOrgContext, attachAbility, async (c) => {
  const format = c.req.query('format')
  // The caller's readable content scope (D-08), the same set the search
  // transport scopes on and provably equal to `accessibleByDrizzle(ability,
  // 'read', 'Wiki'|'Signal')` — see core/authz/readable-workspaces.dbtest.
  // A caller with no memberships gets an empty export, not everything.
  const workspaceIds = (c.get('readableWorkspaceIds') as string[] | undefined) ?? []

Recommendation

Emit an audit_log record for both export and erasure, capturing the actor, scope and time.

Remediation comment

Pending.

TEC-019
Open
Native better-auth organization endpoints bypass the app’s org-management controls and audit
Informational · 0.5
AO:S/AC:L/AX:M/R:P/S:U/C:N/I:M/A:L/P:L

Description

The organization plugin is registered with the stock owner/admin access-control statements and only one hook, afterAcceptInvitation; the before hook intercepts only sign-in, sign-up, reset-password and consent paths. The app's real member-management logic — credential revocation, workspace-member and guardian cleanup, invitation cancel, and audit — lives on the custom /org/* routes, not on the native /api/auth/organization/* endpoints. So remove-member, update-member-role, update and delete are reachable by org admins and bypass those compensating controls: a member removed via the native endpoint keeps their sessions and tokens (and mcpRevoked treats a missing member row as "not revoked" on the legacy path), and no audit row is written. The precise per-endpoint internals depend on better-auth 1.7.0-rc.2 and were not executed in this checkout (see the Test Plan coverage note); the app-side gap is verifiable here.

Code location

The only organization hook wired (afterAcceptInvitation) from server/src/modules/iam/auth.ts, lines 251–260:
        afterAcceptInvitation: async (data) => {
          await handleAcceptedInvitation(db, {
            invitation: data.invitation as typeof data.invitation & {
              id: string
              email?: string
              role?: string | null
            },
            user: { id: data.user.id, name: data.user.name, email: data.user.email },
            organization: { id: data.organization.id },
          })

Recommendation

Add beforeRemoveMember/beforeUpdateMemberRole hooks (or disable the native member/organization mutation endpoints) that run the same credential-revocation, cleanup and audit cascade as the custom /org routes; set disableOrganizationDeletion.

Remediation comment

Pending.

TEC-007
Open
MCP confused deputy: write authorized as the passcode user but executed and attributed as the transport user
Informational · 0.4
AO:S/AC:L/AX:M/R:P/S:U/C:N/I:M/A:N/P:N

Description

On the legacy transport the write gate authorizes against the passcode-resolved user's ability, while the request is handed to the tool handlers with authInfo.clientId set to the transport-authenticated user, and the handlers stamp created_by_user_id from that transport identity. When the two identities differ (a shared-URL topology), a write authorized under one member's permissions is attributed to another — an attribution forgery. The in-code comment claiming "attribution stays the same identity" is not correct for this path.

Code location

The transport-identity clientId passed to the handlers from server/src/routes/mcp.ts, lines 476–478:
  return transport.handleRequest(c.req.raw, {
    authInfo: { token: '', clientId: userId, scopes: principal?.scopes ?? [] },
  })

Recommendation

Use one identity for authorization, execution and attribution on the MCP path — resolve, gate, execute and stamp created_by_user_id from the same authenticated principal.

Remediation comment

Pending.

TEC-013
Open
Capture-upload create-gate authorizes against a caller-asserted org label, not the target workspace’s real org
Informational · 0.4
AO:S/AC:L/AX:H/R:P/S:C/C:N/I:H/A:N/P:L

Description

The capture-upload handler tags the CASL subject with the caller's own orgId from context plus a workspaceId taken from the multipart body, then checks create Entry against that tagged subject. Because the org label is the caller's rather than the target workspace's true owning organization, a super administrator's org-wide create rule matches any workspaceId supplied. This is latent under the one-organization model (no foreign-org workspace exists, and a super admin can already create in any workspace of the sole org), but it is the same broken-object-authorization pattern the domain routes avoid by validating the body workspaceId against the org first.

Hosted multi-org alternative: 1.9 Informational.

Code location

The subject tagged with the caller’s org and a body workspaceId from server/src/modules/capture/routes.ts, lines 120–129:
    const userId = c.get('userId') as string
    const orgId = c.get('orgId') as string
    const targetWorkspaceId = fields.workspaceId ?? (c.get('workspaceId') as string)
    const targetEntry = {
      __caslSubjectType__: 'Entry' as const,
      organizationId: orgId,
      workspaceId: targetWorkspaceId,
    }
    if (!c.get('ability').can('create', targetEntry as unknown as 'Entry')) {
      return c.json({ error: 'Insufficient permissions' }, 403)

Recommendation

Resolve the target workspaceId to its real owning organization and tag the subject with that, as the domain routes do, before the create gate.

Remediation comment

Pending.

TEC-002
Open
Member/invitation passcode written to application logs in cleartext
Informational · 0.3
AO:S/AC:L/AX:M/R:F/S:U/C:M/I:L/A:N/P:L

Description

The invitation hook logs the invitee's freshly generated 4-character passcode, together with the invite URL and organization name, at INFO level. The logger performs no redaction. That passcode is the MCP write-authorization credential (it is copied to the members row), so anyone with log access holds a member's write credential (the first leg of chain TEC-021).

Code location

The invitation hook log line from server/src/modules/iam/auth.ts, lines 239–246:
          .update(schema.invitations)
          .set({ passcode })
          .where(eq(schema.invitations.id, data.invitation.id))

        log.info(
          { email: data.email, inviteUrl, passcode, org: data.organization.name },
          'invitation created — share this URL and passcode with the invitee'
        )

Recommendation

Do not log the passcode; if a diagnostic marker is needed, log a non-reversible reference. Add a logger redaction rule for passcode.

Remediation comment

Pending.

TEC-011
Open
Handlebars validation allowlist is bypassed for structure fields compiled as sub-templates at regen
Informational · 0.2
AO:S/AC:L/AX:L/R:F/S:U/C:N/I:L/A:L/P:N

Description

validatePromptYaml walks and allowlists Handlebars helpers only when a template is present; for a stored override that carries only structure fields (e.g. default_structure), it short-circuits with template == null and never inspects them. Those fields are later compiled as Handlebars sub-templates during wiki regeneration, so the AST allowlist does not apply to them. Handlebars 4.7.9 blocks prototype access, so this is not remote code execution; it is an unvalidated template channel available to any holder of the write permission for wiki types.

Code location

The template == null short-circuit from server/src/lib/prompt-validation.ts, lines 129–137:
  if (spec.template == null) {
    const warnings: string[] = []
    for (const field of stripped) {
      warnings.push(
        `Field "${field}" is reserved for the canonical disk spec and was ignored. The stored value will not affect generation.`
      )
    }
    return { ok: true, spec, warnings }
  }

Recommendation

Run the same helper/AST allowlist over default_structure and structure before they are compiled at regen time.

Remediation comment

Pending.

TEC-016
Open
Branch-protection rulesets require a status check no workflow emits and omit the test jobs
Informational · 0.1
AO:S/AC:L/AX:M/R:F/S:U/C:N/I:L/A:N/P:N

Description

The committed branch-protection rulesets require a status context named verify (typecheck + test), but the workflow emits verify (typecheck + openapi) and the test shards are named in neither ruleset. An unreported required check hard-blocks merges, so the committed state is fail-closed (a merge-availability block), not fail-open. The residual security risk is conditional: if an administrator renames the context to match the workflow, per the ruleset README, without also adding the test shards, the security database tests and the route-allowlist guard would run but no longer gate merges.

Code location

The required status context from .github/rulesets/main-required-checks.json, lines 38–42:
          },
          {
            "context": "verify (typecheck + test)",
            "integration_id": 15368
          },

Recommendation

Point the ruleset at the real emitted context and add the test-suite jobs (including the security *.dbtest shards and the route-allowlist guard) to the required checks.

Remediation comment

Pending.

Controls#

Control verification limited to what the source code evidences. Enforced: code makes violation impossible. Configurable: depends on runtime configuration; the setting is named. Absent: no implementation found. An Absent control that creates exploitable risk is also reported as a finding above.

21 controls checked — 4 enforced, 4 configurable, 13 absent

AreaControlEvidenceState
Tenant isolation Exactly one organization exists per deployment; self-service organization creation is disabled. modules/iam/auth.ts:190, modules/iam/enterprise.ts:91 — org creation only at first sign-up; disabled thereafter. Caveat: DELETE /users/account can hard-delete the sole user and reopen sign-up. Enforced
Tenant isolation The x-workspace-id read scope is validated against the caller’s membership or an open workspace in the same org. core/authz/context.ts:175 — cross-org and private non-member targets return 403. Enforced
Tenant isolation Core content list/detail reads and search are filtered from the session via accessibleByDrizzle. core/authz/accessible-by-drizzle.ts, modules/entries/routes.ts:228 — fail-closed rules→SQL translator, pinned by dbtests. Enforced
Tenant isolation Every content read endpoint is workspace-scoped. modules/entries/routes.ts:313 (TEC-017), routes/settings.ts:167 (TEC-018), modules/signals/routes.ts:216 (TEC-012) — unscoped reads. Absent
Tenant isolation Admin and observability endpoints are workspace/role scoped. modules/admin/routes/graph-stats.ts:33 (TEC-004), admin/routes/admin.ts:120,53 (TEC-003, TEC-005) — session-only or unscoped selects. Absent
Tenant isolation MCP tool tenant scope and write identity derive from the session, not a tool argument or model output. routes/mcp.ts:214 (passcode identity, TEC-006/007), mcp/tool-permissions.ts — write identity is a resolvable passcode; attribution splits from authorization. Absent
Tenant isolation Model output is not used as a cross-tenant write key without a containment check. queue/worker.ts:295 (TEC-010) — model-emitted domainId written cross-workspace with a liveness-only guard. Absent
Tenant isolation Uploads write to a workspace re-checked against its true owning org (not a caller-asserted label). modules/capture/routes.ts:120 (TEC-013) — subject tagged with the caller’s org; latent under the one-org model. Absent
LLM boundary Retrieved content is marked as data, not instruction, in model prompts. agent/ai-edit.ts:333 fences retrieved data; the propose-edit path assembles signals without the same fencing. Absent
LLM boundary The per-wiki system-prompt append is a controlled, within-tenant instruction channel. core/regen/wiki-generation.ts:300 — plain string append to the system message; any update-Wiki holder, same tenant. Configurable
LLM boundary Model tool calls run under the calling user’s permissions, not a service identity. routes/mcp.ts — tools execute under the resolved user’s ability (agent ability is null); ENFORCED for the OAuth path, but the legacy path splits authz from attribution (TEC-007). Configurable
LLM boundary Data sent to the model vendor (OpenRouter) is subject to zero-data-retention / redaction. packages/agent/src/zdr.ts, OPENROUTER_API_KEY — depends on OpenRouter account privacy config; field-level redaction not fully traced (coverage note). Configurable
Retention & deletion pipeline_events have a scheduled retention/prune job. index.ts (setupPrunePipelineEventsScheduler), queue/prune-pipeline-events-worker.ts — daily sweep, defaults 30d/90d. Enforced
Retention & deletion Member removal revokes every credential and cleans workspace/guardian rows. routes/org.ts:461 — the custom DELETE /org/members/:id cascade exists; the native /api/auth/organization/remove-member bypasses it (TEC-019). Absent
Retention & deletion Organization deletion is gated, confirmed and audited. modules/iam/auth.tsdisableOrganizationDeletion unset; native organization/delete cascades data with no confirmation or audit (TEC-019). Absent
Retention & deletion Account erasure removes user content and records the action. modules/users/routes.ts:790 — erasure exists (hard-deletes the users row) but writes no audit record (TEC-014). Configurable
Audit logging Authentication events (sign-in, sign-up, failed login) emit a durable audit record. modules/iam/auth.ts — no emitAuditEvent in the auth hooks. Absent
Audit logging Role/permission changes and member add/remove emit an audit record. routes/org.ts:562,626 audit the custom routes; the native /api/auth/organization/* endpoints do not (TEC-019). Absent
Audit logging Data export and deletion emit an audit record. modules/users/routes.ts:430,790 (TEC-014) — export and erasure emit nothing. Absent
Audit logging MCP tool invocations emit a durable audit record with the acting user. modules/audit/audit.ts:54 (TEC-009) — actor defaults to system/null; denied writes emit no row. Absent
Audit logging Logs and audit detail never contain secrets or credentials. modules/iam/auth.ts:243 (TEC-002) — invitation passcode logged at INFO. Absent