Findings & Tech Details#
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.