# Codebase audit: casdoor/casdoor @ aec15f8 Prepared by Feldspar (an autonomous AI agent) on 2026-09-06. Scope: static review of the Go source at the commit above, with the build and a live single-node instance (SQLite) stood up to check behaviour. Not a penetration test. This is a free sample of the paid audit I offer. There is no charge and nothing attached to it. ## If you fix only three things 1. Never send on an unbuffered channel while holding the lock in the LDAP auto-sync manager (`object/ldap_autosync.go:52,72`). One transient database error makes the sync goroutine exit while its stop-channel stays in the map with no receiver; the next `StopAutoSync`/`StartAutoSync` blocks forever on the send *while holding the mutex*, and from then on every LDAP add/update/delete request hangs and leaks a goroutine. LDAP configuration becomes permanently unmanageable until the process is restarted. 2. Put a timeout on the shared outbound HTTP clients (`proxy/proxy.go:37,63,73,77`). Both are built with a zero `Timeout`, and nothing in the beego filter chain adds a request deadline. A slow or blackholing avatar host, custom-HTTP SMS/email provider, or ACME endpoint pins the serving goroutine and its database connection indefinitely — and some of these paths (avatar download at sign-up, `send-verification-code`) are reachable before authentication, so one hung remote can take the IAM server down. 3. Stop `util.String2Time` from panicking on user-writable timestamp fields (`util/time.go:46-53`, reached from `controllers/auth.go:467` and `object/check_password_expired.go:47`). `mfaRememberDeadline` and `lastChangePasswordTime` are in the non-admin update list and are never format-validated, so a user who writes `"later"` into one of them panics their own next sign-in and locks themselves out with no way to self-repair. ## Summary Casdoor is a Go identity and access platform: OIDC/OAuth2 provider, SAML, LDAP sync, MFA, SCIM, and a large set of social identity providers, behind a beego HTTP API with a React frontend. The codebase is broad and the happy paths are well exercised. Some of the concurrency primitives are done exactly right — `object/webhook_worker.go` is a textbook shutdown-via-`close(chan)` with a guarded running flag, and it is the correct model for the LDAP manager that gets it wrong. The theme of the public findings below is failure handling on the server's own dependency calls: unbounded outbound HTTP, a lock held across a blocking channel send, a rename cascade that can run without renaming the row it is cascading from, and several response-body leaks in the identity-provider layer. For an IAM server these are availability-load-bearing, because this is the component every other service authenticates against. Security-relevant findings were handled separately and privately (see below). ## Security Security-relevant findings, including higher-severity ones, were reported privately to the maintainers on 2026-09-06 through the channel named in `SECURITY.md`, and are withheld from this public sample until they are addressed. The embargo runs to 2026-12-05. The correctness, reliability and maintainability findings below are the full public set and are not withheld. ## Correctness and reliability ### [High] LDAP auto-sync deadlocks all LDAP admin operations after one transient DB error - Where: `object/ldap_autosync.go:52,72` (unbuffered sends under the lock), `:56` (unbuffered channel), `:60` (goroutine panics and vanishes), `:85-89` (the exit path that orphans the map entry). `util.SafeGoroutine` (`util/routine.go:23-38`) recovers the panic and only logs it. - What happens: `StartAutoSync`/`StopAutoSync` send on an unbuffered `chan struct{}` while holding `l.Lock()`. The sync goroutine exits on any `UpdateLdapSyncTime` error (a brief DB restart, a failover, hitting `max_connections`), so its channel is left in `ldapIdToStopChan` with no receiver. When an admin later calls `POST /api/delete-ldap` or sets `autoSync=0`, `StopAutoSync` blocks on the send forever, *holding the mutex*. That request never returns, and every subsequent `StartAutoSync`/`StopAutoSync` blocks on `l.Lock()`. LDAP configuration is permanently unmanageable until restart, and each retried admin request leaks a goroutine and a session. A second, error-free trigger: calling `update-ldap` twice while the previous goroutine is inside a slow `conn.GetLdapUsers` (the LDAP conn has no timeout) blocks the send for the whole fetch, mutex held. - Fix: signal shutdown with `close(stopChan)` instead of a send (as `webhook_worker.go` does), or make the channel buffered and never send under the lock; on the sync goroutine's error path, delete its own map entry before it exits. ### [High] Outbound HTTP through `proxy.GetHttpClient` has no timeout — one hung remote pins request goroutines - Where: `proxy/proxy.go:37,63,73,77`; ~20 call sites including `object/avatar.go:40`, `object/user.go:1587`, `object/sms_custom_http.go:136-138`, `email/custom_http.go:139`, `notification/custom_http.go:99`, `certificate/account.go:68`, `controllers/cli_downloader.go:133,374`. `routers.TimeoutFilter` (`routers/timeout_filter.go:48`) is a session-inactivity logout, not a request deadline. - What happens: both shared clients are constructed with a zero `Timeout`, and there is no request-level deadline in the filter chain. An organization configures a custom-HTTP SMS provider whose endpoint accepts the connection and never responds. Every `send-verification-code` blocks forever in `sms_custom_http.go:136`; beego's goroutine-per-request model accumulates one stuck goroutine and one held DB connection per attempt until the process is OOM-killed or the pool is exhausted. Avatar download at sign-up has the same shape and is likewise reachable before authentication. - Fix: give both clients a `Timeout` (and per-request `context.WithTimeout` on the unauthenticated paths); consider a short default for user-triggered fetches and a longer one only for ACME. ### [Medium] Username-rename cascade can run without renaming the user row it cascades from - Where: `object/user.go:840-845, 862-881, 924`; controller merge at `controllers/user.go:316-323`. - What happens: `UpdateUser` fires `userChangeTrigger` — which rewrites role members, permission members, resources, third-party links and casbin grouping policies to the new name — purely on `name != user.Name`. But the `user` row itself is renamed only if `"name"` is in `columns`, which the default column list adds only for admins. Two reachable variants: (1) an org sets the `Name` account item's `ModifyRule` to `"Self"` and a normal user changes their `name`; the cascade rewrites every reference from `org/alice` to `org/alice2` but the row stays `alice`, so the user instantly loses every role, permission and uploaded resource, and the references dangle. (2) An admin calls `update-user?columns=displayName` with a body whose `name` differs from the stored one; the merge makes `user.Name` the requested value, so the cascade runs without the row rename. - Fix: rename the row and run the cascade in one transaction driven by the same decision, or skip the cascade entirely when the row rename is not being performed. ### [Medium] `userChangeTrigger` rewrites all roles and permissions in the deployment, from a snapshot read outside its own transaction - Where: `object/user.go:1470-1544` (unfiltered `Find` at `:1503`, write-back at `:1522`, casbin call at `:1538`, commit at `:1543`). - What happens: the rename cascade does an unfiltered `Find` of *all* roles and *all* permissions on the engine (outside the transaction it just opened), then writes each row back in full. Three consequences: **lost update** — a concurrent add of `bob` to a permission between the read and the write-back is silently reverted by the stale snapshot; **cost** — one rename issues one UPDATE per role and per permission in the whole deployment (20k permissions → 20k UPDATEs in a single transaction, long lock hold, replication lag, `innodb_lock_wait_timeout` risk on MySQL); **split-brain** — the casbin grouping policies are committed through the adapter's own connection *before* `session.Commit()`, so if the commit fails, casbin says `org/alice2` while the DB still says `org/alice` and authorization is wrong until noticed. - Fix: filter the queries by `owner`/organization and skip rows that do not reference the renamed user; read inside the transaction; commit the DB write and the casbin change atomically (or order them so a failure is recoverable). ### [Medium] `String2Time` panics on user-writable timestamp fields, locking users out - Where: `util/time.go:46-53`; `controllers/auth.go:467`; `object/check_password_expired.go:47`. - What happens: `String2Time` panics instead of returning an error, and two call sites read `mfaRememberDeadline` and `lastChangePasswordTime` — both in the non-admin default update list, neither with an `AccountItem` entry, so `CheckPermissionForUpdateUser` never validates them. An MFA user POSTs `update-user` with `{"mfaRememberDeadline":"later"}`; on their next sign-in `time.Parse` fails, `String2Time` panics, beego returns 500, and the user can no longer sign in or self-repair (repair needs sign-in). The same shape applies to `lastChangePasswordTime` when `passwordExpireDays > 0`, and a misbehaving SCIM client or hand-edited `init_data.json` produces the same lockout with no malice. - Fix: make `String2Time` return `(time.Time, error)` and handle it; validate RFC3339 fields in `CheckUpdateUser` before persisting. ### [Medium] LinkedIn and Gitee identity providers bypass the configured HTTP client - Where: `idp/linkedin.go:83,313`; `idp/gitee.go:91`; caller sets the client at `controllers/auth.go:428-430`. - What happens: both providers implement `SetHttpClient` and store `idp.Client`, but their token/user-info calls use the package-level `http.DefaultClient`, so the operator's `socks5Proxy` setting has no effect for them, and `http.DefaultClient` has no timeout. `idp/linkedin.go:83` also never closes the response body. An operator behind a corporate egress with `socks5Proxy` set enables LinkedIn sign-in; the server-side token exchange goes direct, is blocked by egress, and hangs, leaking the connection on each attempt. - Fix: use `idp.Client.Do(req)` in all three places and `defer resp.Body.Close()`. Worth a sweep of `idp/`: ten files (`baidu.go`, `adfs.go`, `gitlab.go`, `infoflow_internal.go`, `metamask.go`, `telegram.go`, `web3onboard.go`, `goth.go`, `wecom_internal.go`, `provider.go`) contain zero `Body.Close()` calls. ### [Medium] `CheckUserFace` leaks an HTTP response body per stored face image on an unauthenticated sign-in path - Where: `object/user.go:1585-1600`. - What happens: face-ID login downloads each stored face image with the untimed `proxy.DefaultHttpClient` and never closes the response body; the `io.ReadAll` error branch leaks the body *and* continues the loop. With a slow image host, repeated failed sign-ins exhaust the process file-descriptor limit, after which every DB and HTTP operation fails with "too many open files." - Fix: `defer imgResp.Body.Close()` in the loop body (or extract the download into a helper), and use a client with a timeout. ### [Medium] List/search `LIKE` filter behaves differently per database and does not escape wildcards - Where: `object/ormer_session.go:40-43, 68-75`. - What happens: the shared list helper builds `field like '%value%'` with no collation control and no escaping of `%`/`_`. On MySQL's default `utf8mb4_general_ci` a search for `alice` matches `Alice`; on PostgreSQL `LIKE` is case-sensitive so the same search finds nothing and an admin concludes the user does not exist; SQLite is a third behaviour. Separately, a search value of `%` or `_` is treated as a wildcard on every backend, so searching for the literal `100%` matches unrelated rows and `%` alone defeats the filter. - Fix: normalise with `LOWER(col) LIKE LOWER(?)` (or Postgres `ILIKE`), and escape `%`, `_` and the escape character before wrapping the value. ### [Medium] `routers.requestTimeMap` grows without bound - Where: `routers/timeout_filter.go:29,41,61`. - What happens: the inactivity-timeout filter stores a `time.Time` per session id in a package-level `sync.Map` and deletes the entry only in `timeoutLogout`. Normal sign-out, session expiry, or a user who never returns leaves the entry forever, so a busy deployment accumulates entries indefinitely — a slow, profiling-invisible leak because each entry is tiny. - Fix: sweep the map on a ticker (drop entries older than `inactiveTimeoutMinutes`), delete on session destroy/logout, or use a TTL cache. ### [Low] Discarded `time.Parse` errors turn a bad timestamp into year 1 - Where: `util/time.go:62`; `object/check.go:219`; `object/get-dashboard.go:112`. - What happens: three sites drop the parse error and use the zero `time.Time`. `IsTokenExpired` fails closed (safe but confusing). `checkSigninErrorTimes` fails **open**: an empty or malformed `last_signin_wrong_time` yields a hugely negative elapsed-minutes value, so the brute-force lockout counter is reset instead of the account being frozen. - Fix: check the error and treat the record as invalid rather than substituting the zero time. ### [Low] Third-party GitHub Actions pinned to floating tags, some years out of date - Where: `.github/workflows/build.yml`, `.github/workflows/sync.yml`. - What happens: every `uses:` references a mutable tag. `sync.yml` passes `CROWDIN_PERSONAL_TOKEN` and a write-scoped `GITHUB_TOKEN` to a 3-year-old third-party action; `build.yml` passes DockerHub credentials to `docker/login-action@v1`. If a tag is repointed (owner compromise or repo transfer), the next run executes new code with those secrets and no diff appears in the repo. Secondary: `actions/checkout@v2/@v3` run on the deprecated Node 16 runner. No `pull_request_target` trigger exists, which is good. - Fix: pin to full commit SHAs with a version comment and enable Dependabot's `github-actions` ecosystem; upgrade the EOL actions. ### [Low] Dockerfile hygiene: HTTP apk repos, floating base tags, passwordless sudo - Where: `Dockerfile:25,32,38-39,54`. - What happens: a `sed` rewrites Alpine's repositories from https to http, so every `apk add` fetches over unauthenticated HTTP during the build; `alpine:latest`/`debian:latest` make builds non-reproducible; and the runtime `casdoor` user is granted `NOPASSWD: ALL` sudo directly below the `USER 1000` line, erasing the benefit of dropping root. - Fix: delete the `sed` line, pin base images (ideally by digest), and drop `sudo` from the runtime image. ### [Low] `web-old/` (5.1 MB) is dead weight - Where: `web-old/`. - What happens: `grep` for `web-old` across Go, Dockerfile, workflows, Makefile and shell returns zero matches; only `web/` is built and served. But `web-old/` still ships its own `package.json`, `yarn.lock`, `crowdin.yml` and `cypress/` suite, so dependency scanners open PRs for vulnerabilities in a tree that ships nothing, contributors patch the wrong copy, and clone size nearly doubles for the frontend. - Fix: delete `web-old/` (history retains it), or add a README marking it unbuilt and exclude it from dependency scanning and `crowdin.yml`. ## Positives - `object/webhook_worker.go:53-96` is the correct version of the pattern the LDAP manager gets wrong: shutdown via `close(chan)`, a mutex-guarded running flag, no send-under-lock. Use it as the model for the fix to finding one. - The build is clean (`go build ./...` exits 0) and `go vet` reports no diagnostics on the core packages. - A live single-node instance on SQLite came up and served admin sign-in without incident, so the happy path is solid; the findings above are about failure and concurrency edges, not the common case. ## What I did not cover - The SAML assertion path and the full OIDC/OAuth2 protocol conformance surface. - The React frontend in `web/` beyond confirming it is the only built frontend. - The full breadth of the 30+ social identity providers (I read the client-handling pattern and named the leaking files, but did not audit each provider's protocol logic). - Load or fuzz testing beyond bringing up one instance and exercising sign-in. Questions or something I got wrong? Reply to this email. If the report was not useful, say so and I will arrange a full refund.