Skip to content

Fix/audit remediation critical - #124

Merged
BechsteinDigital merged 8 commits into
mainfrom
fix/audit-remediation-critical
Aug 5, 2026
Merged

Fix/audit remediation critical#124
BechsteinDigital merged 8 commits into
mainfrom
fix/audit-remediation-critical

Conversation

@BechsteinDigital

Copy link
Copy Markdown
Owner

1. Why is this change necessary?

The audit tracker #123 blocks any production release on two critical findings, then lists a phase of security-boundary work behind them. Both criticals were exploitable as written.

#102 granted user.create, user.update and user.delete to the workspace admin role in WorkspaceRolePermissions. UserEndpoints checked only that the target user belonged to the caller's workspace, but the operations behind that check mutate the global BackendUser. A workspace administrator could therefore replace the email, display name and password of any member, erase that member's account together with every workspace membership and global RBAC assignment, and export their memberships across all workspaces. For a user who belongs to two workspaces, that is a cross-tenant account takeover. If a platform operator also holds a workspace membership, it is a privilege escalation.

#103 decided bootstrap API-key validity with EnableBootstrapApiKeys && (!RequireApiKeyAuthentication || IsKnownBootstrapKey(providedKey)). With RequireApiKeyAuthentication set to false, any non-empty value in X-Callora-Api-Key authenticated as platform super-admin. A switch named "require authentication" was deciding whether a presented credential is valid at all.

The phase-1 findings share a shape. Each one advertises a guarantee the code does not keep: rate limits partitioned on an attacker-controlled header (#106), a webhook payload carrying a telephone number that no sensitive-field registry knew about (#107), a plugin availability gate that a platform operator walked around with a query parameter (#109), JWTs that outlived password changes and role removals by up to an hour (#105), a password policy that applied only to the bootstrap seed (#104), and WebSocket channels that reassembled fragments into an unbounded buffer (#108).

The two Communication findings are the same problem one layer down. SIP account CRUD wrote rows and reported success while the running voice provider was never reconfigured (#110), and the API offered three SIP authentication methods while the SDK adapter throws for two of them (#111).

2. What does this change do, exactly?

Global identity and workspace membership become separate concerns (#102). Workspace roles keep user.read, because the read endpoints are already filtered to the caller's workspace, and lose every user.* write key. Membership administration moves to membership.read/update/delete on /api/workspaces/{key}/members, where WorkspaceScopeEvaluator.HasWorkspaceAccess confines a workspace-bound caller to its own workspace and answers 404 for any other. Credential changes, account erasure and the data-subject export additionally require platform scope, so a stray permission claim on a workspace token is not enough.

API-key validity depends on the presented key alone (#103). The bootstrap branch now requires IsKnownBootstrapKey unconditionally. RequireApiKeyAuthentication is documented and implemented as a startup policy switch that refuses to boot with bootstrap keys enabled but unconfigured. BootstrapApiKeysExpireAtUtc lets onboarding hand out a break-glass credential that retires itself, and the constant-time comparison runs over SHA-256 digests so key length no longer leaks through the length check.

Rate-limit identity comes from the connection, not the header (#106). BackendRateLimiting.ResolveClientKey reads Connection.RemoteIpAddress only. X-Forwarded-For reaches that address exclusively through UseForwardedHeaders, and BackendForwardedHeaders.Build adds the XForwardedFor flag only once KnownProxies or KnownNetworks names a trusted peer. With empty trust lists ASP.NET applies forwarded headers from any peer, so the header stays unprocessed and startup logs why per-client limits degraded to per-proxy.

Sessions become revocable (#105). Every issued token carries a jti and the account's security stamp. BackendSessionValidator runs in OnTokenValidated and compares that stamp against the stored one, so a password change, deactivation, deletion or RBAC change invalidates outstanding tokens instead of waiting out their hour. Logout records the jti in backend_revoked_sessions, which is durable on purpose because an in-memory list would resurrect logged-out tokens on restart. Account state is cached for fifteen seconds behind a hard entry cap and dropped by SessionStateInvalidatingUserStore the moment a stamp rotates, so revocation is immediate rather than eventual. Tokens without a stamp claim (external OIDC, named integrations) belong to their own issuer and are left alone.

One password policy, lockout, and deactivation (#104). BackendPasswordPolicy governs the bootstrap seed, the demo admin, operator-created accounts and later changes alike. A seed that violates it is skipped with a warning rather than creating the one weak super-admin in the system, which is why the default demo password changed. Ten consecutive failures lock an account for fifteen minutes and a success clears the counter. PUT /api/users/{id}/activation disables an account without deleting it, keeping its data, memberships and audit trail while stopping authentication and live sessions, and both directions are audited. Callora issues no second factor of its own, so RequireExternalIdentityForOperators takes the documented external-identity path instead: it refuses the local password login for platform operators and fails startup without an OIDC authority.

Call events stop leaking the remote number (#107). The Communication manifest declares remoteParty as sensitive, which is the name CallBusinessEvent.ToEventData() actually emits. The manifest documentation showed phoneNumber, callerNumber and calleeNumber, none of which exist in the schema and none of which would have masked anything.

The plugin availability gate reads the effective workspace (#109). PluginAdminWorkspaceResolver resolves the token-bound workspace first and falls back to ?workspaceKey= only for platform operators, matching what the MCP path already did. HostAdminApiRouteScope defaults to Workspace, so a route without a resolvable workspace is rejected with 400 rather than passing through ungated, and a genuinely global route such as plugin status opts out explicitly. Communication's scope helpers no longer read the query themselves, which is what made the bypass reachable.

WebSocket memory is bounded and media tickets are hashed (#108). BoundedWebSocketReader caps reassembly by byte count and tears down an idle socket, closing with MessageTooBig instead of allocating. Audio payloads are checked before decoding and must match the negotiated frame size exactly, which AudioFormat.BytesPerFrame now derives. PacedAudioSender is bounded by total bytes as well as frame count, because many large frames stayed under a count-only cap. MediaStreamSession keeps a SHA-256 lookup key instead of the connect token, rejects future-dated rows that satisfied the old lower-bound TTL check forever, and an hourly job purges spent sessions.

SIP mutations reconcile the runtime (#110). ISipAccountRuntimeReconciler is the single path from a persisted account to a live channel, used by startup and by every mutation, so the two cannot drift. It is state-based and idempotent: an unchanged configuration fingerprint is a no-op, a changed one tears the old registration down before connecting the new one, and a disabled account is deprovisioned. Per-account locking serializes concurrent mutations. A runtime failure is written back onto the account as a failed status with its reason and returned as 502 with the account payload, so the row and the response agree with what the runtime did.

Unsupported SIP authentication is refused rather than advertised (#111). SipAuthMethodSupport is the boundary the admin form, the API and the reconciler share. Create and update answer 422 with a reason that names the upstream gap, the form offers only digest, and the reconciler fails such an account before touching the connector so accounts predating the guard surface that reason on startup.

I disassembled CalloraVoipSdk.Core 4.7.3 before choosing refusal over implementation. SipLineChannel has no path around RegisterAsync and clamps the registration expiry to Math.Max(1, expiry), so a registration-less IP-authenticated trunk cannot be expressed. TlsConfiguration hangs off VoipOptions rather than SipAccount, and SipTlsCertificateProvider holds a single file-loaded certificate, so per-account client certificates cannot be expressed either. Both gaps are now tracked upstream.

Scope

This covers the release gate and phase 1 of #123, plus the first two phase-2 findings. Phases 2 through 4 (#112 through #122) are not in this PR. Two items on #108 also remain open, both of which belong at the host WebSocket layer rather than in the plugin: per-IP and per-token connection limits, and an explicit origin policy for browser-facing endpoints.

3. Describe each step to reproduce the issue or behaviour.

#102, cross-tenant takeover. On main, create a user who is a member of workspace-a and workspace-b. Sign in as an administrator of workspace-a only. Send PUT /api/users/{sharedUser} with a new email and password. The request succeeds and the victim's global credentials are replaced, so their access to workspace-b is now under the attacker's control. DELETE /api/users/{sharedUser} likewise erases both memberships. On this branch both answer 403, and WorkspaceAdminIdentityBoundaryTests covers the victim in two workspaces, the operator who is also a workspace member, and the export.

#103, unknown API key accepted. On main, set BackendHost__EnableBootstrapApiKeys=true and BackendHost__RequireApiKeyAuthentication=false, then call any control-plane endpoint with X-Callora-Api-Key: anything-at-all. The request is authenticated as platform super-admin. On this branch it answers 401, and ApiKeyAuthenticationHandlerTests walks the full on/off matrix of both flags.

#105, stolen token after a password change. Sign in and keep the access token. Change that account's password through PUT /api/users/{id}. Call GET /api/auth/me with the original token. On main it succeeds for the rest of the token's hour. On this branch it answers 401. The same holds for deactivation, deletion and an RBAC role change, and logging out one session leaves the account's other sessions working.

#110, disabled account keeps taking calls. Configure an enabled SIP account and let it register. Call POST /api/.../sip-accounts/{id}/disable. On main the row flips to disabled while the registration stays live until the next restart. On this branch the channel is deregistered before the response returns.

#111, unsupported authentication. Create a SIP account with authMethod: "IpAuthenticated" or "MutualTls". On main it is accepted with 201 and then silently skipped at provisioning, leaving the admin UI on Connecting with no explanation. On this branch it answers 422 naming the upstream issue, and nothing is persisted.

4. Please link to the relevant issues (if any).

fixes #102
fixes #103
fixes #104
fixes #105
fixes #106
fixes #107
fixes #109
fixes #110
fixes #111

related: #108 (per-IP connection limits and the origin policy are still open)
related: #123 (audit tracker)

downstream: BechsteinDigital/callora-voip-sdk#104
downstream: BechsteinDigital/callora-voip-sdk#183

Additional Changes

An early commit on this branch staged custom/ wholesale and picked up the archived Communication plugin's dependency tree, because the ignore pattern covered custom/static-plugins/*/node_modules/ but not the archive's nested one. The files are untracked again and the pattern now covers both the archive and the plugins' app/*/node_modules. The blobs still sit in the intermediate commits, so squash-merging keeps them out of main.

BechsteinDigital and others added 8 commits August 5, 2026 11:27
#102 — Workspace admins held user.create/update/delete, but those endpoints
mutate the global BackendUser. A workspace admin could replace the credentials
of, erase, or export a member who also belongs to other workspaces — a
cross-tenant account takeover. Global identity administration now requires
platform scope; workspace administration moves to a dedicated membership.*
permission set whose endpoints confine a workspace-bound caller to its own
workspace.

#103 — ApiKeyAuthenticationHandler accepted any non-empty key as platform
super-admin whenever RequireApiKeyAuthentication was false. Credential validity
now depends on the presented key alone; RequireApiKeyAuthentication is a startup
policy switch only. Bootstrap keys additionally honour an optional expiry so the
break-glass credential can retire itself, and the constant-time comparison runs
over fixed-length digests so key length no longer leaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
#106 — The rate limiter read the raw first X-Forwarded-For value, so rotating
the header handed out a fresh login bucket per request. It now partitions on
Connection.RemoteIpAddress only. X-Forwarded-For rewrites that address solely
through UseForwardedHeaders, and only once KnownProxies/KnownNetworks name a
trusted peer — with empty trust lists ASP.NET would accept the header from any
client, so the header stays unprocessed and startup logs why.

#107 — CallBusinessEvent serialises the remote telephone number as remoteParty,
which neither the Communication manifest nor the core baseline declared, so
webhooks shipped it in the clear at IncludeSensitiveData=false. The manifest now
declares the field, and a test dispatches a real CallBusinessEvent through the
production dispatcher and minimizer. The manifest documentation showed field
names the code never emits; it now mirrors the shipped file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
#109 — The admin extension proxy checked plugin availability only against the
token-bound workspace. A platform operator has none and selects the target with
?workspaceKey=, which reached Communication's scope resolution ungated: the
plugin could be operated for a workspace where it is unentitled, inactive or
unhealthy.

The host now resolves the effective workspace itself — bound workspace first, a
query selection only for platform operators — and gates availability against it,
matching the MCP path. Routes declare their scope: HostAdminApiRouteScope
defaults to Workspace, so a route without a resolvable workspace is rejected with
400 rather than passing through, and a genuinely global route (plugin status)
opts out explicitly. Communication's scope helpers no longer read the query,
which is what made the bypass reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
#105 — JWTs carried role and permission claims for an hour with no jti, no
security stamp and no server-side record, so password changes, role removal,
deletion and logout left an issued token fully usable. Every session now carries
a jti and the account's security stamp. Rotating the stamp — password change,
deactivation, deletion, RBAC assignment or grant change — revokes all of that
account's sessions; logout records the jti in a durable revocation list and kills
exactly one. The request-path check reads a size-capped 15-second cache that is
dropped on rotation, so revocation is immediate rather than eventual. Tokens
without a stamp (external OIDC, named integrations) stay with their own issuer.

#104 — One BackendPasswordPolicy now governs the bootstrap seed, the demo admin,
operator-created accounts and later credential changes; a violating seed is
skipped instead of creating a weak super-admin, which is why the default demo
password changed. Ten consecutive failures lock an account for fifteen minutes;
a success clears the counter. Accounts can be disabled without deletion, which
keeps their data and memberships while stopping authentication and live
sessions, and both directions are audited. For MFA the host takes the documented
external-identity path: RequireExternalIdentityForOperators refuses the local
password login for platform operators and fails startup without an OIDC
authority, rather than pretending to a second factor Callora does not issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
#108 (partial) — Media and signalling channels reassembled fragments into an
unbounded MemoryStream, so one peer could grow host memory simply by never
setting EndOfMessage. Reassembly now runs through a shared reader with a hard
byte cap and an idle timeout: an oversized message closes the socket with
MessageTooBig instead of allocating, and an abandoned socket is torn down.

Audio payloads are checked before decoding and must match the negotiated frame
size exactly — AudioFormat now derives BytesPerFrame, which turns the negotiated
format into an enforceable constraint. The paced sender is bounded by total bytes
as well as frame count, because many large frames stayed under a count-only cap.

Connect tokens are no longer stored in the clear: the session row keeps a SHA-256
lookup key, so a leaked row hands out no working ticket. Activation additionally
rejects future-dated rows, which satisfied the old lower-bound TTL check forever,
and an hourly job purges spent and expired sessions.

Still open on this issue: per-IP/token connection limits and an explicit origin
policy for browser-facing endpoints, both of which belong at the host WebSocket
layer rather than in the plugin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
#110 — Enabled accounts were provisioned once at plugin startup and the CRUD
handlers only wrote rows. A created or enabled account therefore did not register
until the next restart, a disabled or deleted one kept its registration and could
still take calls, and credential or endpoint changes never reconnected — while
the API reported success either way.

ISipAccountRuntimeReconciler is now the single path from a persisted account to a
live channel, used by startup and by every mutation, so the two cannot drift. It
is state-based and idempotent: an unchanged account is a no-op, a changed
configuration fingerprint tears the old registration down before connecting the
new one, and a disabled account is deprovisioned. Per-account locking serializes
concurrent mutations.

A runtime failure is no longer invisible: the account's status is written back as
failed with its reason and the caller gets 502 carrying both, so the persisted
state and the response agree with what the runtime did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
An earlier commit on this branch swept custom/ wholesale and picked up the
archived Communication plugin's dependency tree. The .gitignore pattern only
covered custom/static-plugins/*/node_modules, not the archive's nested one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
…nect

#111 — The admin UI and API advertised digest, IP-authenticated and mutual-TLS
accounts, while SdkSipAccountFactory throws for everything but digest. Such an
account was accepted, persisted, then silently skipped at provisioning, leaving
the UI on "Connecting" with no explanation.

Verified against CalloraVoipSdk 4.7.3 that neither can be implemented here:
SipLineChannel has no path around RegisterAsync and clamps the expiry to
Math.Max(1, …), so a registration-less trunk is impossible; TlsConfiguration
hangs off VoipOptions rather than SipAccount and SipTlsCertificateProvider holds
one file-loaded certificate, so per-account client certificates are impossible.
Both gaps are now tracked upstream as callora-voip-sdk#104 and #183.

SipAuthMethodSupport is the single boundary the UI, the API and the provisioner
share. Create and update refuse an unsupported method with 422 and a reason that
names the upstream gap; the admin form offers only what the backend accepts; the
reconciler fails such an account before touching the connector, so accounts
predating the guard surface that reason on startup instead of staying invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ7bVnUeBsMfELhMDFizEo
@BechsteinDigital
BechsteinDigital merged commit 18d9636 into main Aug 5, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment