mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
Tests / Unit Tests (push) Failing after 24s
Tests / Integration Tests (push) Failing after 26s
Build , Vet Test, and Lint / Build (push) Successful in 1m7s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m34s
3515 lines
144 KiB
Markdown
3515 lines
144 KiB
Markdown
# Audit: `pkg/security`
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Package** | `github.com/bitechdev/ResolveSpec/pkg/security` |
|
||
| **Size** | 35 non-test files, **11 256 lines** — the largest package in the repo |
|
||
| **Key files** | `oauth_server.go` (1 306), `providers.go` (1 274), `middleware.go` (737), `oauth2_examples.go` (615), `passkey_provider.go` (540), `oauth2_methods.go` (511), `providers_direct.go` (473), `provider.go` (458), `hooks.go` (441), `totp_provider_database.go` (326), `keystore_database.go` (297), `sql_names.go` (267), `query_mode.go` (169), `composite.go` (120), `table_names.go` (101), `chain.go` (57) |
|
||
| **Schema** | `database_schema.sql`, `database_schema_sqlite.sql`, `keystore_schema.sql` |
|
||
| **Docs** | `README.md`, `SECURITY_FEATURES.md`, `QUICK_REFERENCE.md`, `OAUTH2.md`, `OAUTH2_REFRESH_*.md`, `PASSKEY_QUICK_REFERENCE.md`, `KEYSTORE.md` |
|
||
| **Tests** | 6 359 lines across 13 `_test.go` files |
|
||
| **Audit date** | 2026-09-29 |
|
||
| **Axes** | thread locking/waiting, slowness, security, panic handling & logging |
|
||
| **Threat model** | hostile internet client; request bodies, headers, query params, schema/table/column names and filter expressions all attacker-controlled |
|
||
| **Depth** | deep |
|
||
|
||
## Summary
|
||
|
||
This is the package that decides who may read and write what. Three of its four
|
||
pillars do not hold.
|
||
|
||
**Authentication does not verify passwords.** `loginDirect`
|
||
(`providers_direct.go:30-87`) never reads `req.Password`: it selects the user by
|
||
username, mints a session token, and returns it. The shipped stored procedures do
|
||
the same — `resolvespec_login` has the `crypt()` check commented out
|
||
(`database_schema.sql:124-129`), as does `resolvespec_jwt_login`
|
||
(`database_schema.sql:353-357`). The file comment at `providers_direct.go:20-23`
|
||
states this is deliberate ("the stored procedures never verify the password hash
|
||
either… Direct mode matches that behavior exactly"). So in **both** query modes,
|
||
knowing a username is sufficient to obtain a valid 24-hour session. Registration
|
||
stores the password in cleartext (`providers_direct.go:125`,
|
||
`database_schema.sql:506-512`) and lets the registrant choose their own
|
||
`user_level` and `roles`. `resolvespec_jwt_login` returns the stored password in
|
||
its JSON payload (`database_schema.sql:367`). This is finding 1, and it is the
|
||
finding that matters: every other control in this package sits behind it.
|
||
|
||
**Row-level security is silently inert.** `applyRowSecurity` builds the WHERE
|
||
clause, logs `"Applying row security filter…"` at `Info`, and then tries to attach
|
||
it through a type assertion for `interface{ Where(string, ...interface{})
|
||
interface{} }` (`hooks.go:134-136`). No type in this repository has that method:
|
||
`common.SelectQuery.Where` returns `SelectQuery`, not `interface{}`
|
||
(`pkg/common/interfaces.go:47`). I verified with a compiled program that the
|
||
assertion fails and that only the corrected signature matches. The clause is
|
||
discarded, the miss is recorded at `Debug` (`hooks.go:139`) while the preceding
|
||
`Info` line claims success, and every tenant sees every row. The one part of row
|
||
security that does work is the `HasBlock` deny at `hooks.go:92-95` — which is
|
||
exactly what a manual test would exercise, so the failure looks like success.
|
||
Finding 2.
|
||
|
||
**Column masking has large holes.** `setColSecValue`
|
||
(`provider.go:256-299`) dispatches on `strings.ToLower(fieldval.Kind().String())`.
|
||
`float64` and `float32` match no case; `bool` matches no case; `time.Time` has
|
||
`Kind() == struct`, so the `"time"`/`"date"` case never fires; `[]byte` has
|
||
`Kind() == slice`, and the JSON branch it would need tests `fieldTypeName`, which
|
||
the caller passes as the **column name** (`provider.go:355`), not a type. Salary,
|
||
balance, date-of-birth and JSONB columns are therefore returned unmasked. And
|
||
`ApplyColumnSecurity` carries `defer logger.CatchPanic("ApplyColumnSecurity")()`
|
||
(`provider.go:302`) on a function with unnamed results, so any panic inside makes
|
||
it return a zero `reflect.Value` **and a nil error** — `applyColumnSecurity` reads
|
||
that as success and the response goes out unmasked. `GetRowSecurityTemplate` has
|
||
the same construct (`provider.go:443`) and fails open to "no security". Findings
|
||
3, 4, 5.
|
||
|
||
**Performance: every secured request serialises on two global mutexes, each held
|
||
across a database round trip.** `LoadColumnSecurity` holds
|
||
`ColumnSecurityMutex.Lock()` from `provider.go:375` across the provider call at
|
||
`:388`; `LoadRowSecurity` holds `RowSecurityMutex.Lock()` from `:424` across the
|
||
provider call at `:433`. Neither honours the `pOverwrite` cache flag that
|
||
`hooks.go:46`/`:59` pass — both always call the provider and always overwrite — so
|
||
there is no caching at all. Maximum throughput for the whole API becomes
|
||
`1 / (2 × provider_latency)`, and a slow database converts into a total stall,
|
||
because a mutex wait ignores the request's context deadline. Finding 6.
|
||
|
||
**Credentials reach the logs and the error tracker.** `providers.go:391` logs the
|
||
raw `Authorization` header at `Warn` whenever it contains a comma — attacker
|
||
triggerable, and per cross-cutting finding X8 every `Warn` is forwarded to Sentry.
|
||
`hooks.go:93` logs `%v` of the user ref at `Warn`; for every spec adapter that ref
|
||
is the whole `*UserContext`, whose `SessionID` field is the live session token
|
||
(`interfaces.go:13`, set at `providers_direct.go:77`). `hooks.go:129-130` prints
|
||
the same struct at `Info` on every secured read. Finding 7.
|
||
|
||
What is genuinely good, and should be protected in any remediation: the OAuth 2.1
|
||
authorization-code flow in `oauth_server.go` is carefully built — S256 PKCE is
|
||
mandatory (`:550-555`), `redirect_uri` is matched exactly against the registered
|
||
list (`:562`, `:602`), authorization codes are single-use under lock
|
||
(`:840-845`), client secrets are hashed and compared with
|
||
`subtle.ConstantTimeCompare` (`:1259`). The password-reset flow is textbook: 32
|
||
random bytes, SHA-256 at rest, one-hour expiry, a deliberately generic response to
|
||
prevent user enumeration, and all sessions deleted on completion
|
||
(`providers_direct.go:310-406`). Those two flows show the package knows how to do
|
||
this; the gap is that the primary login path never got the same treatment.
|
||
|
||
## Findings
|
||
|
||
| # | Severity | Axis | Finding |
|
||
|---|---|---|---|
|
||
| 1 | **Critical** | security | Password is never verified — in Direct mode *and* in the shipped stored procedures; passwords stored in cleartext; self-registration chooses its own roles |
|
||
| 2 | **Critical** | security | Row-level security is silently inert: the `Where` type assertion at `hooks.go:134` can never match (verified) |
|
||
| 3 | **Critical** | security / panic handling | `defer logger.CatchPanic(...)()` on unnamed results makes `ApplyColumnSecurity` and `GetRowSecurityTemplate` return "success, no rules" after a panic |
|
||
| 4 | **High** | security | `setColSecValue` never masks `float*`, `bool`, `time.Time` or `[]byte`; its JSON branch tests the column *name*, not the type (verified) |
|
||
| 5 | **High** | security | `loadSecurityRules` fails open on every error path — a transient provider error yields an unfiltered, unmasked query |
|
||
| 6 | **High** | thread locking / slowness | Two package-wide write mutexes each held across a provider DB call, with `pOverwrite` ignored so nothing is ever cached |
|
||
| 7 | **High** | security / logging | Session tokens and raw `Authorization` headers written to logs and forwarded to Sentry |
|
||
| 8 | **High** | security | `HeaderAuthenticator` trusts `X-User-ID`/`X-User-Roles` from the client and is the authenticator used in the README's complete example |
|
||
| 9 | **High** | security / slowness | One request can submit unlimited comma-separated tokens; each is tried against the database in turn |
|
||
| 10 | **High** | security | Logout does not invalidate the cached session when the token carries a `Bearer ` prefix — the key used to delete differs from the key used to store |
|
||
| 11 | **High** | security | Unauthenticated dynamic client registration: any caller registers a client with self-chosen scopes, and the `clients` map is never pruned |
|
||
| 12 | **High** | security | `JWTAuthenticator.Login` issues `token_<userid>_<expiry>` as a bearer token — trivially forgeable — and never verifies the password |
|
||
| 34 | **High** | security | `lookupOrFetchClient` rehydrates a persisted client without its secret hash, so after a restart every confidential client is treated as public and client authentication is skipped |
|
||
| 13 | **Medium** | security | `refresh_token` grant authenticates no client and binds the token to none |
|
||
| 14 | **Medium** | security | Both `SecurityList` maps grow without bound, and their keys embed session tokens and JWT claims |
|
||
| 15 | **Medium** | security | Model rules fail open: unregistered models allow update and delete; `CheckModelAuthAllowed` grants every authenticated user every operation |
|
||
| 16 | **Medium** | security / logging | `logDataAccess` is a `logger.Info` line with a TODO — there is no audit trail |
|
||
| 17 | **Medium** | security | `ValidateSQLNames` / `ValidateTableNames` exist but are never called; names are interpolated into SQL unvalidated |
|
||
| 18 | **Medium** | security | Internal error text echoed to unauthenticated clients on every auth failure |
|
||
| 19 | **Medium** | security / panic handling | `probeFunctionExists` swallows a panic into `false`, silently downgrading the whole authenticator to Direct mode |
|
||
| 20 | **Medium** | thread locking | `go a.updateSessionActivity(r.Context(), …)` — unbounded goroutine per request, no recover, and the context is already cancelled |
|
||
| 21 | **Medium** | thread locking | `cleanupStates` and `cleanupExpired` goroutines: one unstoppable and leaked per `WithOAuth2` call |
|
||
| 22 | **Medium** | security | `ApplyColumnSecurity` returns an error when a table has *no* rules, which `hooks.go:184` logs at `Warn` — one Sentry event per read |
|
||
| 23 | **Medium** | security | Ephemeral RS256 signing key generated per process; `id_token`s break on restart and across replicas |
|
||
| 24 | **Medium** | correctness | `contains` is prefix-or-suffix, not substring — primary-key detection misses `bun:"id,pk"` and `extractSQLName` returns `"column:name"` verbatim |
|
||
| 25 | **Medium** | correctness | `maskString` masks one character too many at each end and indexes runes by byte offset (verified) |
|
||
| 26 | **Low** | thread locking | Unsynchronised nil-map reads outside the lock in `ApplyColumnSecurity` and `GetRowSecurityTemplate` — a real race under `-race` |
|
||
| 27 | **Low** | security | `SecurityList`'s maps and mutexes are exported, so any importer can mutate the security cache |
|
||
| 28 | **Low** | correctness | `ColumSecurityApplyOnRecord` shadows `i` three times and indexes one slice with another's index |
|
||
| 29 | **Low** | correctness | `ClearSecurity`'s filter condition is `&&` where it must be `||` (dead code — zero callers) |
|
||
| 30 | **Low** | slowness | `splitTag` builds strings with `+=` inside a rune loop — O(n²) per struct tag, on every secured read |
|
||
| 31 | **Low** | correctness | `registerDirect` uses `LastInsertId`, unsupported on Postgres, and checks uniqueness outside a transaction |
|
||
| 32 | **Low** | security | `Authenticate` may return `(nil, nil)` through the callback, and the caller dereferences it |
|
||
| 33 | **Low** | security | `requestPasswordReset` returns the raw reset token to its caller |
|
||
|
||
---
|
||
|
||
## 1. Critical — the password is never verified, in either query mode
|
||
|
||
`loginDirect` reads exactly one thing from the request: the username.
|
||
|
||
```go
|
||
// providers_direct.go:30-46
|
||
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||
var userID int
|
||
var email, roles, programUserTable sql.NullString
|
||
var userLevel, programUserID sql.NullInt64
|
||
|
||
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||
query := rewritePlaceholders(db, fmt.Sprintf(
|
||
`SELECT id, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE username = ? AND is_active = ?`,
|
||
a.tableNames.Users))
|
||
return db.QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
||
})
|
||
```
|
||
|
||
`req.Password` appears nowhere in the function. Execution continues straight to
|
||
`generateSessionToken()` at `:48` and the session row is inserted at `:60`. Any
|
||
request naming an existing active user receives a valid 24-hour session token.
|
||
|
||
This is not an oversight in one code path. The file header states it as policy:
|
||
|
||
```go
|
||
// providers_direct.go:20-23
|
||
// Password verification is intentionally not implemented here: the stored
|
||
// procedures never verify the password hash either (see the TODOs in
|
||
// database_schema.sql), so Direct mode matches that behavior exactly rather
|
||
// than introducing a mismatch between modes.
|
||
```
|
||
|
||
And the stored procedures confirm it:
|
||
|
||
```sql
|
||
-- database_schema.sql:119-129 (resolvespec_login)
|
||
IF NOT FOUND THEN
|
||
RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
||
RETURN;
|
||
END IF;
|
||
|
||
-- TODO: Verify password hash using pgcrypto extension
|
||
-- Enable pgcrypto: CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||
-- IF NOT (crypt(p_request->>'password', v_password_hash) = v_password_hash) THEN
|
||
-- RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
||
-- RETURN;
|
||
-- END IF;
|
||
```
|
||
|
||
`resolvespec_jwt_login` is the same (`database_schema.sql:353-357`), and
|
||
`JWTAuthenticator.Login` repeats it a third time in Go
|
||
(`providers.go:687-690`).
|
||
|
||
Three consequences compound it:
|
||
|
||
**Passwords are stored in cleartext.** The hashing step is commented out in the
|
||
procedure:
|
||
|
||
```sql
|
||
-- database_schema.sql:506-512
|
||
-- TODO: Hash password using pgcrypto extension
|
||
-- Enable pgcrypto: CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||
-- v_password := crypt(v_password, gen_salt('bf'));
|
||
|
||
-- Create new user
|
||
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table)
|
||
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now(), v_program_user_id, v_program_user_table)
|
||
```
|
||
|
||
and Direct mode inserts `req.Password` verbatim (`providers_direct.go:125`), as
|
||
does password reset (`providers_direct.go:392`, `UPDATE … SET password = ?` with
|
||
`req.NewPassword`). The column comment still says "bcrypt hashed password"
|
||
(`database_schema.sql:9`), so a reader of the schema would not notice.
|
||
|
||
**The stored password is returned to the caller.** `resolvespec_jwt_login`
|
||
includes it in its result payload:
|
||
|
||
```sql
|
||
-- database_schema.sql:360-370
|
||
RETURN QUERY SELECT
|
||
true,
|
||
NULL::text,
|
||
jsonb_build_object(
|
||
'id', v_user_id,
|
||
'username', v_username,
|
||
'email', v_email,
|
||
'password', v_password,
|
||
...
|
||
```
|
||
|
||
and `JWTAuthenticator.Login` unmarshals it into a struct field
|
||
(`providers.go:678`). It is not placed in `LoginResponse`, so it is not returned
|
||
over the wire — but it is now in a live Go value that any `%+v` log line or
|
||
error-tracker breadcrumb would capture.
|
||
|
||
**Self-registration chooses its own privileges.** `registerDirect` writes
|
||
`req.UserLevel` and `strings.Join(req.Roles, ",")` straight from the request
|
||
(`providers_direct.go:100`, `:125`), and the procedure takes the same fields from
|
||
`p_request` (`database_schema.sql:445` documents
|
||
`{username, password, email, user_level, roles, …}`). A client that can reach
|
||
`Register` becomes `user_level: 99, roles: ["admin"]`.
|
||
|
||
Finally, note that this reaches the OAuth server: `authorizePost` calls the same
|
||
login (`oauth_server.go:605-620`), so `/oauth/authorize` issues authorization
|
||
codes to anyone who names a valid username.
|
||
|
||
### Remediation
|
||
|
||
The fix is not a patch, it is the missing feature. In order:
|
||
|
||
1. Add a real verifier. In Go, `golang.org/x/crypto/bcrypt`:
|
||
```go
|
||
var hash string
|
||
// …SELECT id, password, … FROM users WHERE username = ? AND is_active = ?
|
||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
||
return nil, fmt.Errorf("invalid credentials") // same text as the not-found path
|
||
}
|
||
```
|
||
Keep the error text and the timing identical to the user-not-found branch
|
||
(`providers_direct.go:42-43`) — compare against a dummy hash when the user does
|
||
not exist, so the two paths cost the same.
|
||
2. In the procedures, enable `pgcrypto` and uncomment the three `crypt()` blocks.
|
||
3. Hash on write: `crypt(v_password, gen_salt('bf'))` in `resolvespec_register`,
|
||
and `bcrypt.GenerateFromPassword` in `registerDirect` and
|
||
`completePasswordResetDirect`.
|
||
4. Remove `'password', v_password` from `resolvespec_jwt_login`'s payload and the
|
||
`Password` field from the struct at `providers.go:678`.
|
||
5. Do not let a registration request set `user_level` or `roles`. Take them from
|
||
server-side policy; if a deployment genuinely needs client-supplied roles,
|
||
intersect against an allow-list.
|
||
6. Ship a migration for existing rows. Cleartext passwords cannot be converted —
|
||
rehash on next successful login, or force a reset.
|
||
7. Add a test that asserts login **fails** with a wrong password, for both modes.
|
||
The absence of that test is what let this ship: `providers_test.go` covers the
|
||
success path only.
|
||
|
||
Until step 1 lands, no other finding in this file can be assessed as mitigated,
|
||
because an attacker does not need to bypass column masking or row filters if they
|
||
can log in as the data's owner.
|
||
|
||
---
|
||
|
||
## 2. Critical — row-level security is silently inert everywhere
|
||
|
||
`applyRowSecurity` does all the work and then throws it away.
|
||
|
||
```go
|
||
// hooks.go:126-141
|
||
// Generate the WHERE clause from template
|
||
whereClause := rowSec.GetTemplate(pkName, modelType)
|
||
|
||
logger.Info("Applying row security filter for user %v on %s.%s: %s",
|
||
userRef, schema, tablename, whereClause)
|
||
|
||
// Apply the WHERE clause to the query
|
||
query := secCtx.GetQuery()
|
||
if selectQuery, ok := query.(interface {
|
||
Where(string, ...interface{}) interface{}
|
||
}); ok {
|
||
secCtx.SetQuery(selectQuery.Where(whereClause))
|
||
} else {
|
||
logger.Debug("Query doesn't support Where method, skipping row security")
|
||
}
|
||
```
|
||
|
||
The asserted method must return `interface{}`. The query that
|
||
`restheadspec/handler.go:924` places in the hook context is a
|
||
`common.SelectQuery`, whose `Where` returns `SelectQuery`:
|
||
|
||
```go
|
||
// pkg/common/interfaces.go:47
|
||
Where(query string, args ...interface{}) SelectQuery
|
||
```
|
||
|
||
Go interface satisfaction requires the method signature to match exactly,
|
||
including the result type, so the assertion fails for every query type in this
|
||
repository. I confirmed there is no other candidate: grepping for
|
||
`Where(string, ...interface{}) interface{}` finds only the assertion itself, and
|
||
`UpdateQuery.Where` (`pkg/common/interfaces.go:87`) and `DeleteQuery.Where`
|
||
(`:98`) return their own concrete interfaces too. I then compiled a minimal
|
||
program with a `SelectQuery`-shaped type and verified the assertion fails while
|
||
the corrected `Where(string, ...interface{}) SelectQuery` form succeeds.
|
||
|
||
Two things make this hard to notice:
|
||
|
||
* The `Info` line at `:129` is emitted **before** the assertion and says
|
||
"Applying row security filter … `<clause>`". The only record of the failure is
|
||
the `Debug` at `:139`, which is off in production
|
||
(`zap.NewProductionConfig()`, `logger/logger.go:24`). The logs assert that
|
||
row security is working.
|
||
* The deny path still works. `HasBlock` returns an error at `hooks.go:92-95`
|
||
before any of this, so "block user X from table Y" behaves correctly. A
|
||
reviewer who tests blocking concludes row security is live.
|
||
|
||
This affects every spec: `restheadspec/security_hooks.go:31-34`,
|
||
`resolvespec/security_hooks.go`, `websocketspec/security_hooks.go`,
|
||
`mqttspec/security_hooks.go`, `resolvemcp/security_hooks.go` and
|
||
`funcspec/security_adapter.go` all register `ApplyRowSecurity` against the same
|
||
`hooks.go` implementation.
|
||
|
||
### Remediation — and why the obvious fix is dangerous
|
||
|
||
The naive correction is to assert the real interface:
|
||
|
||
```go
|
||
if selectQuery, ok := query.(common.SelectQuery); ok {
|
||
secCtx.SetQuery(selectQuery.Where(whereClause))
|
||
}
|
||
```
|
||
|
||
Do not ship that alone. It would turn a dead code path into a SQL injection
|
||
sink, because of how the clause is built:
|
||
|
||
```go
|
||
// provider.go:43-50
|
||
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
||
str := m.Template
|
||
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
||
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
||
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
||
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID))
|
||
return str
|
||
}
|
||
```
|
||
|
||
`m.UserID` is typed `any` (`provider.go:40`) and for every spec adapter it is the
|
||
whole `*UserContext` (`restheadspec/security_hooks.go:83-89`), so
|
||
`fmt.Sprintf("%v", …)` renders `&{1 alice 0 sess_… [user] alice@example.com
|
||
map[…] …}` — including JWT-derived `UserName`, `Email` and `Claims` — directly
|
||
into a WHERE clause that `hooks.go:137` passes with **no bind arguments**. A
|
||
template of `user_id = {UserID}` plus an attacker-influenced claim yields
|
||
arbitrary SQL. The two defects currently mask each other.
|
||
|
||
Fix both together:
|
||
|
||
1. Change the template contract to emit a placeholder and a bind value:
|
||
```go
|
||
func (m *RowSecurity) GetTemplate(pk string, t reflect.Type) (string, []any) {
|
||
str := m.Template
|
||
str = strings.ReplaceAll(str, "{PrimaryKeyName}", quoteIdent(pk))
|
||
str = strings.ReplaceAll(str, "{TableName}", quoteIdent(m.Tablename))
|
||
str = strings.ReplaceAll(str, "{SchemaName}", quoteIdent(m.Schema))
|
||
if !strings.Contains(str, "{UserID}") {
|
||
return str, nil
|
||
}
|
||
return strings.ReplaceAll(str, "{UserID}", "?"), []any{m.userIDScalar()}
|
||
}
|
||
```
|
||
where `userIDScalar()` returns an `int`/`string` — never a struct. Reject a
|
||
`*UserContext` here outright; a provider that needs a claim should put that
|
||
claim in `RowSecurity.UserID` itself.
|
||
2. Then assert `common.SelectQuery` and pass the args:
|
||
`selectQuery.Where(clause, args...)`.
|
||
3. Make the failure loud. A security filter that cannot be attached must fail the
|
||
request, not log at `Debug`:
|
||
```go
|
||
selectQuery, ok := query.(common.SelectQuery)
|
||
if !ok {
|
||
return fmt.Errorf("row security: query type %T does not support Where", query)
|
||
}
|
||
```
|
||
4. Add a test that asserts the generated SQL contains the filter. A unit test on
|
||
`applyRowSecurity` with a fake `SelectQuery` recording its calls would have
|
||
caught this on day one.
|
||
|
||
Note also that `hooks.go:113-124` walks `modelType.NumField()` without checking
|
||
`modelType.Kind() == reflect.Struct` first; a non-struct model panics there. The
|
||
spec handlers recover (`restheadspec/handler.go:396-400`), so it surfaces as a
|
||
500 rather than a crash.
|
||
|
||
---
|
||
|
||
## 3. Critical — `CatchPanic` on unnamed results converts a panic into "allowed"
|
||
|
||
Two functions in `provider.go` defer `logger.CatchPanic`:
|
||
|
||
```go
|
||
// provider.go:301-306
|
||
func (m *SecurityList) ApplyColumnSecurity(records reflect.Value, modelType reflect.Type, pUserID int, pSchema, pTablename string) (reflect.Value, error) {
|
||
defer logger.CatchPanic("ApplyColumnSecurity")()
|
||
|
||
if m.ColumnSecurity == nil {
|
||
return records, fmt.Errorf("security not initialized")
|
||
}
|
||
```
|
||
|
||
```go
|
||
// provider.go:442-447
|
||
func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) {
|
||
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
||
|
||
if m.RowSecurity == nil {
|
||
return RowSecurity{}, fmt.Errorf("security not initialized")
|
||
}
|
||
```
|
||
|
||
`CatchPanic` returns a closure that calls `recover()`
|
||
(`logger/logger.go:153-186`). Recovering in a deferred function on a function
|
||
whose results are **unnamed** means the function returns its results' zero
|
||
values — here `(reflect.Value{}, nil)` and `(RowSecurity{}, nil)`. The `nil`
|
||
error is the problem: both callers treat it as success.
|
||
|
||
For column masking:
|
||
|
||
```go
|
||
// hooks.go:182-192
|
||
maskedResult, err := securityList.ApplyColumnSecurity(resultValue, modelType, userID, schema, tablename)
|
||
if err != nil {
|
||
logger.Warn("Column security error: %v", err)
|
||
// Don't fail the request, just log the issue
|
||
return nil
|
||
}
|
||
|
||
// Update the result with masked data
|
||
if maskedResult.IsValid() && maskedResult.CanInterface() {
|
||
secCtx.SetResult(maskedResult.Interface())
|
||
}
|
||
```
|
||
|
||
`err` is nil, `maskedResult.IsValid()` is false, so nothing happens and
|
||
`applyColumnSecurity` returns nil — "masking applied successfully". The handler
|
||
then serialises the untouched records (`restheadspec/handler.go:1006` sends
|
||
`modelPtr`, the same backing array `ApplyColumnSecurity` was supposed to mutate in
|
||
place). The client receives **unmasked** data and the response is a 200.
|
||
|
||
For row security, `GetRowSecurityTemplate` returning `(RowSecurity{}, nil)` gives
|
||
`HasBlock == false` and `Template == ""`, so `applyRowSecurity` returns nil at
|
||
`hooks.go:143` — "this table has no row security". A panic while looking up a
|
||
blocking rule therefore **unblocks** the user.
|
||
|
||
This is reachable. `setColSecValue` calls `fieldval.SetString`
|
||
(`provider.go:275`, `:277`), `SetZero` (`:271`) and `SetBytes` (`:295`) with no
|
||
`CanSet()` guard — only the integer branch checks (`:266`). A rule naming an
|
||
unexported field, or a `[]byte` branch reached on a non-byte slice, panics. One
|
||
such rule aborts masking for **all** columns and **all** records in the response,
|
||
silently.
|
||
|
||
The panic is logged (`Error` + Sentry via `CatchPanicCallback`), so there is a
|
||
trace — but the request still succeeds with unprotected data.
|
||
|
||
### Remediation
|
||
|
||
Name the results so the recovered value can be converted into a failure, and make
|
||
the caller fail closed:
|
||
|
||
```go
|
||
func (m *SecurityList) ApplyColumnSecurity(records reflect.Value, modelType reflect.Type,
|
||
pUserID int, pSchema, pTablename string) (out reflect.Value, err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
out = reflect.Value{}
|
||
err = logger.HandlePanic("ApplyColumnSecurity", r) // logs + returns an error
|
||
}
|
||
}()
|
||
...
|
||
}
|
||
```
|
||
|
||
`logger.HandlePanic` (`logger/logger.go:197-211`) already does exactly this and is
|
||
the right helper here; `CatchPanic` is only appropriate on functions that return
|
||
nothing. Same change at `provider.go:443`.
|
||
|
||
Then close the caller:
|
||
|
||
```go
|
||
// hooks.go:183
|
||
if err != nil {
|
||
return fmt.Errorf("column security failed for %s.%s: %w", schema, tablename, err)
|
||
}
|
||
```
|
||
|
||
— see finding 22 for why that requires fixing the "no rules" error first, and
|
||
finding 5 for the same change on the load path. Separately, guard the reflect
|
||
writes in `setColSecValue` with `CanSet()` so the panic does not arise at all, and
|
||
have it return an error the caller aggregates rather than the ignored
|
||
`(int, reflect.Value)` pair.
|
||
|
||
`_CROSS-CUTTING` finding X8 covers the general `CatchPanic`-on-unnamed-results
|
||
hazard; this package is where it has the highest cost.
|
||
|
||
---
|
||
|
||
## 4. High — four common column types are never masked
|
||
|
||
`setColSecValue` decides what to do by string-matching the reflect *kind*:
|
||
|
||
```go
|
||
// provider.go:256-299 (abridged)
|
||
fieldKindLower := strings.ToLower(fieldval.Kind().String())
|
||
switch {
|
||
case strings.Contains(fieldKindLower, "int") && (mask || hide):
|
||
if fieldval.CanInt() && fieldval.CanSet() {
|
||
fieldval.SetInt(0)
|
||
}
|
||
case (strings.Contains(fieldKindLower, "time") || strings.Contains(fieldKindLower, "date")) && (mask || hide):
|
||
fieldval.SetZero()
|
||
case strings.Contains(fieldKindLower, "string"):
|
||
...
|
||
case strings.Contains(fieldTypeName, "json") && (mask || hide):
|
||
...
|
||
}
|
||
```
|
||
|
||
Working through the kinds a model actually uses:
|
||
|
||
| Go type | `Kind().String()` | Matches? | Result |
|
||
|---|---|---|---|
|
||
| `int`, `int64`, `uint8` | `int`, `int64`, `uint8` | yes (`"uint8"` contains `"int"`) | zeroed |
|
||
| `string` | `string` | yes | masked |
|
||
| **`float64`, `float32`** | `float64`, `float32` | **no** — `"float64"` does not contain `"int"` | **returned in full** |
|
||
| **`bool`** | `bool` | **no** | **returned in full** |
|
||
| **`time.Time`** | `struct` | **no** — `"struct"` contains neither `"time"` nor `"date"` | **returned in full** |
|
||
| **`[]byte` / JSONB** | `slice` | **no** — see below | **returned in full** |
|
||
| `*string` | (dereferenced at `:258-260`) | yes | masked |
|
||
|
||
So `salary float64`, `account_balance`, `is_vip bool`, `date_of_birth time.Time`
|
||
and every JSONB column pass through a "mask" rule untouched. I verified the string
|
||
matching: `strings.Contains("float64", "int")` is false, `strings.Contains("uint8",
|
||
"int")` is true, and `reflect.TypeOf(time.Time{}).Kind().String()` is `"struct"`.
|
||
|
||
The JSON branch cannot fire for the intended reason. It tests `fieldTypeName`, and
|
||
the caller passes the **column name**:
|
||
|
||
```go
|
||
// provider.go:353-356
|
||
if i == pathLen-1 {
|
||
if nameType == "sql" || nameType == "struct" {
|
||
setColSecValue(field, *colsec, fieldName)
|
||
}
|
||
```
|
||
|
||
where `fieldName` was assigned from `cols.SQLName` or `cols.Name`
|
||
(`provider.go:341`, `:347`). The branch therefore fires only when the column
|
||
happens to be *named* something containing "json" — and when it does fire on a
|
||
non-`[]byte` field, `fieldval.Bytes()` at `:285` panics, which finding 3 turns
|
||
into a silent unmasked response. Conversely a JSONB column mapped to `string`
|
||
matches the earlier `"string"` case and has its **entire document** masked instead
|
||
of the configured path.
|
||
|
||
Also note the switch has no default: an unmatched kind returns `(0, fieldsrc)`
|
||
indistinguishably from success, and both return values are discarded at the call
|
||
site.
|
||
|
||
### Remediation
|
||
|
||
Dispatch on the type, not on a substring of the kind's name, and report what could
|
||
not be handled:
|
||
|
||
```go
|
||
func setColSecValue(fieldsrc reflect.Value, colsec ColumnSecurity, sqlName string) error {
|
||
fieldval := fieldsrc
|
||
for fieldval.Kind() == reflect.Pointer || fieldval.Kind() == reflect.Interface {
|
||
if fieldval.IsNil() {
|
||
return nil
|
||
}
|
||
fieldval = fieldval.Elem()
|
||
}
|
||
if !fieldval.CanSet() {
|
||
return fmt.Errorf("column %s: field not settable", sqlName)
|
||
}
|
||
hide := strings.EqualFold(colsec.Accesstype, "hide")
|
||
|
||
// Concrete types first — before any Kind switch.
|
||
switch fieldval.Interface().(type) {
|
||
case time.Time:
|
||
fieldval.SetZero()
|
||
return nil
|
||
case []byte:
|
||
return maskJSONPath(fieldval, colsec) // the gjson/sjson path
|
||
}
|
||
|
||
switch fieldval.Kind() {
|
||
case reflect.String:
|
||
if hide { fieldval.SetString(""); return nil }
|
||
fieldval.SetString(maskString(...))
|
||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||
fieldval.SetInt(0)
|
||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||
fieldval.SetUint(0)
|
||
case reflect.Float32, reflect.Float64:
|
||
fieldval.SetFloat(0)
|
||
case reflect.Bool:
|
||
fieldval.SetBool(false)
|
||
default:
|
||
return fmt.Errorf("column %s: unsupported kind %s for %s",
|
||
sqlName, fieldval.Kind(), colsec.Accesstype)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
Decide the JSON case on the field's type (`[]byte`, `json.RawMessage`, or a
|
||
driver-specific JSONB wrapper), never on its name. `ApplyColumnSecurity` should
|
||
collect the returned errors and propagate them; with finding 3 fixed, an
|
||
unmaskable column then fails the request instead of leaking.
|
||
|
||
Test matrix worth adding: one model with `string`, `*string`, `int64`, `float64`,
|
||
`bool`, `time.Time`, `[]byte` and an unexported field, each with `mask` and `hide`,
|
||
asserting the value actually changed.
|
||
|
||
---
|
||
|
||
## 5. High — `loadSecurityRules` fails open on every path
|
||
|
||
```go
|
||
// hooks.go:32-67
|
||
func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error {
|
||
userID, ok := secCtx.GetUserID()
|
||
if !ok {
|
||
logger.Warn("No user ID in context for security check")
|
||
return nil
|
||
}
|
||
...
|
||
err := securityList.LoadColumnSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
||
if err != nil {
|
||
logger.Warn("Failed to load column security: %v", err)
|
||
// Don't fail the request if no security rules exist
|
||
// return err
|
||
}
|
||
...
|
||
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
|
||
if err != nil {
|
||
logger.Warn("Failed to load row security: %v", err)
|
||
// Don't fail the request if no security rules exist
|
||
// return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
Three fail-open points, with the `return err` commented out at two of them. The
|
||
stated rationale — "don't fail the request if no security rules exist" — conflates
|
||
*no rules* with *could not determine the rules*. The consequences differ
|
||
completely:
|
||
|
||
* A provider timeout, a closed connection, a missing stored procedure, a
|
||
permissions error on the security tables: `ColumnSecurity[key]` is left empty
|
||
and `RowSecurity[key]` is not written, so the subsequent `ApplyColumnSecurity`
|
||
finds no rules and `GetRowSecurityTemplate` reports "no row security". The
|
||
request proceeds **unmasked and unfiltered**. An attacker who can induce load on
|
||
the security database gets a window of unrestricted reads.
|
||
* `GetUserID` is `ctx.Value(UserIDKey).(int)` (`middleware.go:402-405`). Any
|
||
deployment whose user identifier is not an `int` — a UUID subject from an OIDC
|
||
provider, for example — gets `ok == false` and **no rules are loaded at all**,
|
||
for every request. The `Warn` at `:36` is the only signal, and per X8 it floods
|
||
Sentry with one event per request.
|
||
|
||
Note `GetUserRef` (`:55-58`) was added to handle non-integer identifiers, but
|
||
`loadSecurityRules` still returns early on the `GetUserID` check before reaching
|
||
it, and `LoadColumnSecurity`'s signature is `int`-only
|
||
(`provider.go:370`), so column security cannot be loaded for a non-integer user
|
||
at all.
|
||
|
||
### Remediation
|
||
|
||
Distinguish "no rules" from "unknown". Have the provider contract say so — e.g.
|
||
a sentinel `ErrNoSecurityRules` that the loader treats as success and everything
|
||
else as failure:
|
||
|
||
```go
|
||
if err := securityList.LoadColumnSecurity(ctx, userID, schema, tablename, false); err != nil {
|
||
if !errors.Is(err, ErrNoSecurityRules) {
|
||
return fmt.Errorf("column security unavailable for %s.%s: %w", schema, tablename, err)
|
||
}
|
||
}
|
||
```
|
||
|
||
and register the hook so a returned error aborts the request. For the user-ID
|
||
path, widen `LoadColumnSecurity` to take the same `any` ref `LoadRowSecurity`
|
||
takes, and drop the `GetUserID` gate in favour of `GetUserRef`:
|
||
|
||
```go
|
||
userRef, ok := secCtx.GetUserRef()
|
||
if !ok {
|
||
return fmt.Errorf("no user identity in context") // fail closed
|
||
}
|
||
```
|
||
|
||
If a deployment genuinely wants unauthenticated reads, that is what
|
||
`ModelRules.CanPublicRead` is for (`hooks.go:254`) — an explicit opt-in per model,
|
||
not an implicit consequence of a missing context value.
|
||
|
||
---
|
||
|
||
## 6. High — two global mutexes, each held across a database call, with no caching
|
||
|
||
```go
|
||
// provider.go:370-395
|
||
func (m *SecurityList) LoadColumnSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) error {
|
||
if m.provider == nil {
|
||
return fmt.Errorf("security provider not set")
|
||
}
|
||
|
||
m.ColumnSecurityMutex.Lock()
|
||
defer m.ColumnSecurityMutex.Unlock()
|
||
...
|
||
if pOverwrite || m.ColumnSecurity[secKey] == nil {
|
||
m.ColumnSecurity[secKey] = make([]ColumnSecurity, 0)
|
||
}
|
||
|
||
// Call the provider to load security rules
|
||
colSecList, err := m.provider.GetColumnSecurity(ctx, pUserID, pSchema, pTablename)
|
||
if err != nil {
|
||
return fmt.Errorf("GetColumnSecurity failed: %v", err)
|
||
}
|
||
|
||
m.ColumnSecurity[secKey] = colSecList
|
||
return nil
|
||
}
|
||
```
|
||
|
||
```go
|
||
// provider.go:419-440 (abridged)
|
||
m.RowSecurityMutex.Lock()
|
||
defer m.RowSecurityMutex.Unlock()
|
||
...
|
||
record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
|
||
...
|
||
m.RowSecurity[secKey] = record
|
||
```
|
||
|
||
Three problems, compounding.
|
||
|
||
**The lock spans the round trip.** `GetColumnSecurity` and `GetRowSecurity` are
|
||
the provider interface (`interfaces.go:133-137`); the shipped implementations are
|
||
`DatabaseColumnSecurityProvider` / `DatabaseRowSecurityProvider`, which issue SQL.
|
||
The write lock is therefore held for the full database latency. Every secured
|
||
request calls both loaders (`hooks.go:46`, `:59`), so the ceiling on the whole
|
||
API's throughput is `1 / (2 × provider_latency)` — with a 5 ms security query,
|
||
about 100 req/s regardless of how many cores or connections are available.
|
||
|
||
**A slow database becomes a total stall.** `sync.Mutex.Lock` has no timeout and
|
||
ignores `ctx`. If the security provider's connection pool saturates, every
|
||
in-flight and future request blocks in `Lock()` rather than failing with the
|
||
request's deadline. Cancelled clients still hold their place in the queue.
|
||
|
||
**There is no caching, so this happens on every request.** Both functions take a
|
||
`pOverwrite` parameter and both effectively ignore it: `LoadColumnSecurity`
|
||
consults it only to reset the slice at `:383` and then unconditionally queries at
|
||
`:388` and overwrites at `:393`; `LoadRowSecurity` does not reference `pOverwrite`
|
||
at all. `hooks.go:46` and `:59` both pass `false` — expecting a cache — and get a
|
||
database call anyway. The two maps are thus write-only caches: they accumulate
|
||
entries (finding 14) that are never read as a hit.
|
||
|
||
`GetRowSecurityTemplate` (`:442-458`) does take only `RLock`, so the read side is
|
||
concurrent — but it reads the entry that was just overwritten microseconds
|
||
earlier under the write lock, so the read lock buys nothing.
|
||
|
||
### Remediation
|
||
|
||
1. **Never call the provider under the lock.** Load first, then publish:
|
||
```go
|
||
func (m *SecurityList) LoadColumnSecurity(ctx context.Context, ref any, schema, table string, overwrite bool) error {
|
||
key := colSecKey(schema, table, ref)
|
||
|
||
if !overwrite {
|
||
m.mu.RLock()
|
||
_, hit := m.columnSecurity[key]
|
||
m.mu.RUnlock()
|
||
if hit {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
list, err := m.provider.GetColumnSecurity(ctx, ref, schema, table) // no lock held
|
||
if err != nil {
|
||
return fmt.Errorf("GetColumnSecurity failed: %w", err)
|
||
}
|
||
|
||
m.mu.Lock()
|
||
m.columnSecurity[key] = entry{rules: list, loadedAt: time.Now()}
|
||
m.mu.Unlock()
|
||
return nil
|
||
}
|
||
```
|
||
Two callers may duplicate one query on a cold key; that is strictly better than
|
||
serialising all of them. If duplicate work matters, collapse it with
|
||
`golang.org/x/sync/singleflight` keyed on `key` — that also keeps a thundering
|
||
herd off the security database.
|
||
2. **Honour `pOverwrite` and add a TTL.** Security rules that never expire are
|
||
also a correctness problem: revoking a mask takes effect only on restart. A
|
||
short TTL (30–60 s) plus an explicit invalidate is the usual shape. The
|
||
`Cacheable` interface the composite provider already probes for
|
||
(`composite.go:99-120`) is the natural hook.
|
||
3. **Consider the existing cache package.** `pkg/cache` already provides TTL,
|
||
tag-based invalidation and a size cap; `DatabaseAuthenticator` uses it for
|
||
sessions (`providers.go:402`). Reusing it here would fix caching, bounding and
|
||
invalidation in one move — and see `cache.audit.md` findings 1, 2 and 7-9 for
|
||
the caveats to apply when doing so.
|
||
4. **Wrap the provider call with a deadline** derived from the request context so a
|
||
hung security query fails the request rather than occupying a slot.
|
||
|
||
Note that fixing the lock is what *enables* finding 5's fail-closed change to be
|
||
safe: failing closed on a provider error is only acceptable once a provider stall
|
||
cannot take the whole process down with it.
|
||
|
||
---
|
||
|
||
## 7. High — session tokens and `Authorization` headers are written to logs and Sentry
|
||
|
||
Three places log credentials.
|
||
|
||
**The raw `Authorization` header, at `Warn`:**
|
||
|
||
```go
|
||
// providers.go:389-392
|
||
// Log warning if multiple tokens are provided
|
||
if len(tokens) > 1 {
|
||
logger.Warn("Multiple authentication tokens provided in Authorization header (%d tokens). This is unusual and may indicate a misconfigured client. Header: %s", len(tokens), sessionToken)
|
||
}
|
||
```
|
||
|
||
`sessionToken` here is the unparsed header value (`:354`). Any client can trigger
|
||
this by sending `Authorization: Bearer abc, Bearer def` — so an attacker can
|
||
choose what gets written, and a legitimate client with a quirk writes its real
|
||
token. Per `_CROSS-CUTTING` X8 every `logger.Warn` is forwarded to the error
|
||
tracker (`logger/logger.go:118-122`), so the token leaves the host entirely and
|
||
lands in Sentry's retention.
|
||
|
||
**The whole `UserContext`, including `SessionID`, at `Warn` and `Info`:**
|
||
|
||
```go
|
||
// hooks.go:92-95
|
||
if rowSec.HasBlock {
|
||
logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename)
|
||
return fmt.Errorf("access denied to %s", tablename)
|
||
}
|
||
```
|
||
|
||
```go
|
||
// hooks.go:129-130
|
||
logger.Info("Applying row security filter for user %v on %s.%s: %s",
|
||
userRef, schema, tablename, whereClause)
|
||
```
|
||
|
||
`userRef` comes from `GetUserRef`, which returns the full `*UserContext`
|
||
(`restheadspec/security_hooks.go:83-89`, and identically in the websocketspec,
|
||
mqttspec, resolvespec, resolvemcp and funcspec adapters). `fmt`'s `%v` on a
|
||
pointer-to-struct prints every field:
|
||
|
||
```go
|
||
// interfaces.go:9-23
|
||
type UserContext struct {
|
||
UserID int `json:"user_id"`
|
||
UserName string `json:"user_name"`
|
||
UserLevel int `json:"user_level"`
|
||
SessionID string `json:"session_id"`
|
||
...
|
||
Claims map[string]any `json:"claims"`
|
||
Meta map[string]any `json:"meta"`
|
||
```
|
||
|
||
`SessionID` holds the live session token — `loginDirect` assigns
|
||
`SessionID: sessionToken` (`providers_direct.go:77`), as does `registerDirect`
|
||
(`:169`). So `hooks.go:93` sends a working session token to Sentry, and
|
||
`hooks.go:129` writes one to the log file on **every secured read of every table
|
||
that has a row-security template**. `Claims` is the full OIDC userinfo map when an
|
||
external provider is used (`oauth2_methods.go:356`), so JWT claims go with it.
|
||
|
||
`hooks.go:87` (`logger.Debug("No row security for %s.%s@%v: %v", …, userRef, err)`)
|
||
has the same content but at `Debug`, which production configuration suppresses and
|
||
which X8 does not forward — lower risk, same fix.
|
||
|
||
### Remediation
|
||
|
||
1. Never format a `UserContext` with `%v`. Give it a redacting `String()`:
|
||
```go
|
||
func (u *UserContext) String() string {
|
||
if u == nil { return "<nil user>" }
|
||
return fmt.Sprintf("user{id=%d name=%q level=%d roles=%v}", u.UserID, u.UserName, u.UserLevel, u.Roles)
|
||
}
|
||
```
|
||
`fmt` will use it for both `%v` and `%s`, fixing all three call sites at once
|
||
— and also shrinking the `SecurityList` map keys in finding 14. Pair it with a
|
||
`MarshalJSON` that omits `SessionID`, so the struct cannot leak through a JSON
|
||
log encoder either.
|
||
2. At `providers.go:391`, drop the header from the message. The count is the
|
||
actionable part:
|
||
`logger.Warn("Authorization header carried %d tokens; using the first that validates", len(tokens))`.
|
||
Better still, make it `Debug` — a multi-token header is client-triggerable, so
|
||
at `Warn` it is also an attacker-controlled Sentry flood (X8).
|
||
3. Lower `hooks.go:129` to `Debug`. "Row security applied" on every read is not a
|
||
`Warn`-worthy event, and at `Info` it doubles the log volume of the read path.
|
||
4. Repo-wide, grep for `%v` applied to anything reaching a `UserContext`,
|
||
`LoginRequest` (which has a `Password` field, `interfaces.go:28`) or
|
||
`LoginResponse` before considering this closed.
|
||
|
||
---
|
||
|
||
## 8. High — `HeaderAuthenticator` trusts the client, and the README recommends it
|
||
|
||
```go
|
||
// providers.go:18-23
|
||
// Production-Ready Authenticators
|
||
// =================================
|
||
|
||
// HeaderAuthenticator provides simple header-based authentication
|
||
// Expects: X-User-ID, X-User-Name, X-User-Level, X-Session-ID, X-Remote-ID, X-User-Roles, X-User-Email
|
||
type HeaderAuthenticator struct{}
|
||
```
|
||
|
||
```go
|
||
// providers.go:45-64
|
||
func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
||
userIDStr := r.Header.Get("X-User-ID")
|
||
if userIDStr == "" {
|
||
return nil, fmt.Errorf("X-User-ID header required")
|
||
}
|
||
|
||
userID, err := strconv.Atoi(userIDStr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid user ID: %w", err)
|
||
}
|
||
|
||
return &UserContext{
|
||
UserID: userID,
|
||
UserName: r.Header.Get("X-User-Name"),
|
||
UserLevel: parseIntHeader(r, "X-User-Level", 0),
|
||
...
|
||
Roles: parseRoles(r.Header.Get("X-User-Roles")),
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
Every field comes from a request header. `curl -H 'X-User-ID: 1' -H
|
||
'X-User-Roles: admin' -H 'X-User-Level: 99'` authenticates as user 1 with
|
||
administrative roles. There is no shared secret, no signature, no trusted-proxy
|
||
list, and nothing that distinguishes a header injected by a gateway from one sent
|
||
by the client.
|
||
|
||
This pattern is legitimate — but only behind a proxy that strips the headers from
|
||
inbound requests and re-adds them after authenticating. Nothing in this package
|
||
says so. It sits under a heading that says "Production-Ready", and it is the
|
||
authenticator in the README's end-to-end wiring example:
|
||
|
||
```go
|
||
// pkg/security/README.md:655-666
|
||
"public.orders": "user_id = {UserID}",
|
||
}
|
||
|
||
// Create providers
|
||
auth := security.NewHeaderAuthenticator()
|
||
colSec := security.NewConfigColumnSecurityProvider(columnRules)
|
||
rowSec := security.NewConfigRowSecurityProvider(rowTemplates, nil)
|
||
|
||
// Combine providers and register hooks
|
||
provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||
securityList := security.NewSecurityList(provider)
|
||
restheadspec.RegisterSecurityHooks(handler, securityList)
|
||
```
|
||
|
||
A developer following that example ships an API where authentication is a header.
|
||
`README.md:298` does mark `DatabaseAuthenticator` as "(Recommended)", but the
|
||
complete example contradicts it.
|
||
|
||
Reachability: `NewHeaderAuthenticator` has **no non-test callers** in this
|
||
repository (only its own constructor at `providers.go:25-26`), so nothing here is
|
||
currently exposed. This is a hazard shipped to consumers, not a live
|
||
vulnerability — which is why it is High rather than Critical.
|
||
|
||
### Remediation
|
||
|
||
Do not delete it; make misuse hard.
|
||
|
||
1. Require the trust boundary to be declared. Give the constructor a mandatory
|
||
configuration:
|
||
```go
|
||
type HeaderAuthenticatorConfig struct {
|
||
TrustedProxies []netip.Prefix // required, no default
|
||
RequiredSecret string // optional shared secret in X-Auth-Proxy-Secret
|
||
}
|
||
func NewHeaderAuthenticator(cfg HeaderAuthenticatorConfig) (*HeaderAuthenticator, error) {
|
||
if len(cfg.TrustedProxies) == 0 {
|
||
return nil, errors.New("HeaderAuthenticator requires TrustedProxies: these headers are client-controlled")
|
||
}
|
||
...
|
||
}
|
||
```
|
||
and have `Authenticate` reject any request whose direct peer
|
||
(`r.RemoteAddr`, **not** `X-Forwarded-For` — see `middleware.audit.md` finding 2)
|
||
is outside the list. Compare the secret with `subtle.ConstantTimeCompare`.
|
||
2. Move it out from under "Production-Ready Authenticators", and put the warning in
|
||
the doc comment rather than only in prose: *"trusts unauthenticated request
|
||
headers; safe only behind a reverse proxy that strips `X-User-*` from inbound
|
||
requests."*
|
||
3. Change the README's complete example to `NewDatabaseAuthenticator(db)`. If a
|
||
header example is wanted, show it with the trusted-proxy configuration and the
|
||
nginx/envoy `proxy_set_header` lines that make it sound.
|
||
|
||
---
|
||
|
||
## 9. High — one request can brute-force unlimited session tokens
|
||
|
||
```go
|
||
// providers.go:365-396 (abridged)
|
||
} else {
|
||
// Parse Authorization header which may contain multiple comma-separated tokens
|
||
// Format: "Token abc, Token def" or "Bearer abc" or just "abc"
|
||
rawTokens := strings.Split(sessionToken, ",")
|
||
for _, token := range rawTokens {
|
||
token = strings.TrimSpace(token)
|
||
token = strings.TrimPrefix(token, "Bearer ")
|
||
token = strings.TrimPrefix(token, "Token ")
|
||
token = strings.TrimSpace(token)
|
||
if token != "" {
|
||
tokens = append(tokens, token)
|
||
}
|
||
}
|
||
}
|
||
...
|
||
// Try each token until one succeeds
|
||
var lastErr error
|
||
for _, token := range tokens {
|
||
```
|
||
|
||
`strings.Split` on an attacker-supplied header with no cap on the number of
|
||
elements, feeding a loop that performs one cache lookup and, on a miss, one
|
||
database round trip per element (`:402-438`).
|
||
|
||
`Authorization: a,b,c,…` with 10 000 elements is one HTTP request that issues
|
||
10 000 session lookups. Two consequences:
|
||
|
||
* **Rate-limit bypass for credential stuffing.** Any per-request throttle — and
|
||
per `middleware.audit.md` finding 1 there is currently none mounted at all —
|
||
counts this as a single request while it tests 10 000 candidate tokens.
|
||
* **Amplified load.** Each miss is a `QueryRowContext` against the session
|
||
procedure. The request body can be empty; the header does the work. Go's default
|
||
`MaxHeaderBytes` is 1 MiB, which at ~4 bytes per element allows on the order of
|
||
200 000 lookups per request.
|
||
|
||
The loop also stops at the *first* token that validates, so a valid token placed
|
||
last still works after thousands of failures — there is no early abort.
|
||
|
||
### Remediation
|
||
|
||
1. Cap the count, and reject rather than truncate:
|
||
```go
|
||
const maxAuthTokens = 4
|
||
if len(rawTokens) > maxAuthTokens {
|
||
return nil, fmt.Errorf("too many authorization tokens")
|
||
}
|
||
```
|
||
Four is generous; RFC 7235 expects one credential per header. If the
|
||
multi-token behaviour exists for a specific client, one is the right number
|
||
and that client should be fixed.
|
||
2. Abort the loop on the first *hard* failure. "Invalid or expired session"
|
||
(`:424`) means this credential is wrong — there is no reason to keep trying
|
||
unless the caller legitimately holds several. Distinguish it from a transport
|
||
error, which should fail the request rather than fall through to the next token.
|
||
3. Charge the rate limiter per token, not per request, once a limiter is mounted
|
||
(`middleware.audit.md` finding 1).
|
||
4. Drop the `Warn` on the multi-token path (finding 7) — with a cap in place, the
|
||
log line loses its purpose and its Sentry cost.
|
||
|
||
---
|
||
|
||
## 10. High — logout does not invalidate a cached `Bearer ` session
|
||
|
||
`Authenticate` strips the scheme prefix **before** building the cache key:
|
||
|
||
```go
|
||
// providers.go:371-374, 398
|
||
token = strings.TrimPrefix(token, "Bearer ")
|
||
token = strings.TrimPrefix(token, "Token ")
|
||
token = strings.TrimSpace(token)
|
||
...
|
||
cacheKey := fmt.Sprintf("auth:session:%s", token)
|
||
```
|
||
|
||
`Logout` builds the key from the **unstripped** request field:
|
||
|
||
```go
|
||
// providers.go:316-320
|
||
// Clear cache for this token
|
||
if req.Token != "" {
|
||
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
|
||
_ = a.cache.Delete(ctx, cacheKey)
|
||
}
|
||
```
|
||
|
||
`logoutDirect` has the same split — it strips for the SQL but not for the cache:
|
||
|
||
```go
|
||
// providers_direct.go:182-184, 203-206
|
||
token := req.Token
|
||
token = strings.TrimPrefix(token, "Bearer ")
|
||
token = strings.TrimPrefix(token, "bearer ")
|
||
...
|
||
if req.Token != "" {
|
||
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
|
||
_ = a.cache.Delete(ctx, cacheKey)
|
||
}
|
||
```
|
||
|
||
So a client that logs out with `{"token": "Bearer sess_abc…"}` — the natural
|
||
thing to send back, and exactly what `logoutDirect:183` anticipates — deletes
|
||
`auth:session:Bearer sess_abc…`, a key that was never written. The live entry
|
||
`auth:session:sess_abc…` survives. The session row is deleted from the database,
|
||
but `Authenticate` serves the cached `UserContext` without consulting the
|
||
database:
|
||
|
||
```go
|
||
// providers.go:402-403
|
||
err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (any, error) {
|
||
// This function is called only if cache miss
|
||
```
|
||
|
||
The token therefore keeps working until `cacheTTL` expires — five minutes by
|
||
default (`providers.go:134`, `:140`). On a shared or stolen device, "log out"
|
||
does not end the session.
|
||
|
||
`logoutDirect:184` also strips only `"Bearer "` and `"bearer "`, while
|
||
`Authenticate:373` strips `"Token "` as well, so a `Token …`-prefixed logout
|
||
fails to match the database row either (`rows == 0` → `"session not found"` at
|
||
`:199-201`) while the cache entry also survives.
|
||
|
||
### Remediation
|
||
|
||
Normalise once, in one place, and use it everywhere:
|
||
|
||
```go
|
||
// normalizeSessionToken strips any supported scheme prefix. The result is the
|
||
// value used both as the cache key suffix and as the stored session_token.
|
||
func normalizeSessionToken(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
for _, p := range []string{"Bearer ", "bearer ", "Token ", "token "} {
|
||
if rest, ok := cutPrefixFold(s, p); ok {
|
||
return strings.TrimSpace(rest)
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
func sessionCacheKey(token string) string {
|
||
return "auth:session:" + normalizeSessionToken(token)
|
||
}
|
||
```
|
||
|
||
Replace all four sites (`providers.go:398`, `:466`, `:318`;
|
||
`providers_direct.go:183`, `:204`) with these helpers.
|
||
|
||
Two hardening steps beyond the bug:
|
||
|
||
* Have `Logout` delete the cache entry **before** the database call, and again
|
||
after, so a failed database logout still drops the cached credential.
|
||
* Consider whether a 5-minute positive cache on sessions is the right default at
|
||
all. It is a 5-minute window on every revocation, not just logout — password
|
||
reset (`providers_direct.go:395`) deletes session rows but nothing clears their
|
||
cache entries, so a reset password also leaves sessions live for `cacheTTL`.
|
||
`ClearCache(token)` exists (`providers.go:463-471`) but the reset path does not
|
||
call it.
|
||
|
||
Separately: session tokens are stored in the database in plaintext
|
||
(`providers_direct.go:60` inserts `sessionToken` directly, and `sessionDirect:219`
|
||
matches on equality), and they are also cache keys — so both the cache keyspace
|
||
and a database read expose usable credentials. Storing `sha256(token)` and
|
||
matching on the hash would remove both exposures, at the cost of a migration.
|
||
|
||
|
||
---
|
||
|
||
## 11. High — unauthenticated client registration, with self-chosen scopes and no pruning
|
||
|
||
`/oauth/register` is mounted unconditionally:
|
||
|
||
```go
|
||
// oauth_server.go:240
|
||
mux.HandleFunc("/oauth/register", s.registerHandler)
|
||
```
|
||
|
||
and the handler requires nothing but a POST:
|
||
|
||
```go
|
||
// oauth_server.go:406-433 (abridged)
|
||
func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
var req struct {
|
||
RedirectURIs []string `json:"redirect_uris"`
|
||
ClientName string `json:"client_name"`
|
||
GrantTypes []string `json:"grant_types"`
|
||
AllowedScopes []string `json:"allowed_scopes"`
|
||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ... }
|
||
if len(req.RedirectURIs) == 0 { ... }
|
||
grantTypes := req.GrantTypes
|
||
if len(grantTypes) == 0 {
|
||
grantTypes = []string{"authorization_code"}
|
||
}
|
||
allowedScopes := req.AllowedScopes
|
||
if len(allowedScopes) == 0 {
|
||
allowedScopes = s.cfg.DefaultScopes
|
||
}
|
||
```
|
||
|
||
RFC 7591 permits open registration, so this is a defensible default for an MCP
|
||
server — but three specifics make it exploitable here.
|
||
|
||
**The client declares its own scopes.** `allowedScopes := req.AllowedScopes`
|
||
(`:430`) is stored verbatim into the client record (`:467`) with no intersection
|
||
against a server-side permitted set. `s.cfg.DefaultScopes` is used only when the
|
||
request omits the field. A registration asking for
|
||
`"allowed_scopes": ["admin", "write:all"]` gets them.
|
||
|
||
**Registered clients are never removed.** The cleanup goroutine prunes only the
|
||
two short-lived maps:
|
||
|
||
```go
|
||
// oauth_server.go:252-274 (abridged)
|
||
func (s *OAuthServer) cleanupExpired() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-s.done:
|
||
return
|
||
case <-ticker.C:
|
||
now := time.Now()
|
||
s.mu.Lock()
|
||
for k, p := range s.pending {
|
||
if now.After(p.ExpiresAt) { delete(s.pending, k) }
|
||
}
|
||
for k, p := range s.codes {
|
||
if now.After(p.ExpiresAt) { delete(s.codes, k) }
|
||
}
|
||
s.mu.Unlock()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`s.clients` (`:136`, initialised `:189`) has no expiry, no cap and no eviction.
|
||
Each POST to `/oauth/register` adds a permanent entry holding the client name, the
|
||
`redirect_uris` slice, the grant types and the scopes — all attacker-sized. This
|
||
is an unauthenticated, unbounded memory write: the classic shape of a slow
|
||
memory-exhaustion DoS, and there is no rate limiter mounted to slow it
|
||
(`middleware.audit.md` finding 1).
|
||
|
||
**The request body is unbounded.** `json.NewDecoder(r.Body)` at `:418` with no
|
||
`http.MaxBytesReader`. `RedirectURIs`, `GrantTypes` and `AllowedScopes` are
|
||
unbounded slices of unbounded strings, so one request can allocate as much as the
|
||
client is willing to send.
|
||
|
||
Persistence is available but off by default. `PersistClients` (`:37-39`) writes the
|
||
record through `OAuthRegisterClient` (`:472-486`) and `lookupOrFetchClient`
|
||
(`:1108-1135`) reads it back, so a registration can outlive the process — but with
|
||
the default `false`, every registration is lost on restart and invisible to other
|
||
replicas, and with it set the rehydrated record is incomplete in a
|
||
security-relevant way (finding 34).
|
||
|
||
### Remediation
|
||
|
||
1. **Gate registration.** Either require an initial access token (RFC 7591 §3.1) —
|
||
```go
|
||
if s.cfg.RegistrationAccessToken != "" {
|
||
if subtle.ConstantTimeCompare([]byte(bearerFrom(r)), []byte(s.cfg.RegistrationAccessToken)) != 1 {
|
||
writeOAuthError(w, "invalid_token", "", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
}
|
||
```
|
||
— or keep it open and bound it hard: a per-IP registration rate limit plus a
|
||
global cap on `len(s.clients)`, rejecting with `503` once reached.
|
||
2. **Intersect the requested scopes** against what the server is willing to grant:
|
||
```go
|
||
allowedScopes = intersectScopes(req.AllowedScopes, s.cfg.GrantableScopes)
|
||
if len(allowedScopes) == 0 { allowedScopes = s.cfg.DefaultScopes }
|
||
```
|
||
Never store a scope the server did not sanction.
|
||
3. **Expire clients.** Give `oauthClient` a `CreatedAt` and a `LastUsedAt`, and
|
||
prune in `cleanupExpired` alongside `pending` and `codes`. For clients that must
|
||
outlive the process, persist them the way codes are persisted.
|
||
4. **Bound the body and the slices:**
|
||
`r.Body = http.MaxBytesReader(w, r.Body, 32<<10)`, plus explicit limits on the
|
||
number of redirect URIs (say 8) and their length, and `dec.DisallowUnknownFields()`
|
||
so a typo'd field is an error rather than a silent default.
|
||
5. Validate each `redirect_uri` at registration: absolute, no fragment, and
|
||
`https` unless the host is `localhost`. The exact-match check at `:562` makes
|
||
registration the only place this can be enforced.
|
||
|
||
---
|
||
|
||
## 12. High — `JWTAuthenticator` issues a forgeable bearer token and verifies nothing
|
||
|
||
```go
|
||
// providers.go:687-706
|
||
// TODO: Verify password
|
||
// if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||
// return nil, fmt.Errorf("invalid credentials")
|
||
// }
|
||
|
||
// Generate token (placeholder - implement JWT signing when library is available)
|
||
expiresAt := time.Now().Add(24 * time.Hour)
|
||
tokenString := fmt.Sprintf("token_%d_%d", user.ID, expiresAt.Unix())
|
||
|
||
return &LoginResponse{
|
||
Token: tokenString,
|
||
...
|
||
```
|
||
|
||
and identically in Direct mode:
|
||
|
||
```go
|
||
// providers_direct.go:447-451
|
||
expiresAt := time.Now().Add(24 * time.Hour)
|
||
tokenString := fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix())
|
||
|
||
return &LoginResponse{
|
||
Token: tokenString,
|
||
```
|
||
|
||
The "token" is `token_<user_id>_<unix_expiry>`. It is not signed, not random, and
|
||
contains no secret — anyone can construct `token_1_1790000000` for user 1. There
|
||
is no JWT anywhere in the type despite the name; the doc comment at `:692` calls
|
||
it a placeholder.
|
||
|
||
The counterpart is unimplemented:
|
||
|
||
```go
|
||
// providers.go:741-754
|
||
func (a *JWTAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
||
authHeader := r.Header.Get("Authorization")
|
||
if authHeader == "" {
|
||
return nil, fmt.Errorf("authorization header required")
|
||
}
|
||
|
||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||
if tokenString == authHeader {
|
||
return nil, fmt.Errorf("bearer token required")
|
||
}
|
||
|
||
// TODO: Implement JWT parsing when library is available
|
||
return nil, fmt.Errorf("JWT parsing not implemented - install github.com/golang-jwt/jwt/v5")
|
||
}
|
||
```
|
||
|
||
So this provider **cannot authenticate any request**: it fails closed, always.
|
||
That is what keeps this at High rather than Critical — a deployment wiring
|
||
`JWTAuthenticator` into `NewAuthMiddleware` returns 401 to everyone and the
|
||
problem is discovered immediately.
|
||
|
||
The exposure is the seam between the two. `Login` mints tokens that are accepted
|
||
by nothing in this package, so a deployment that wants JWT login has to pair
|
||
`JWTAuthenticator.Login` with a verifier of its own — and the tokens it is handed
|
||
carry no signature to verify. `jwtLogoutDirect` compounds the impression that the
|
||
flow is complete by inserting the token into a blacklist table
|
||
(`providers_direct.go:464-473`) that `Authenticate` never reads.
|
||
|
||
`Login` also does not verify the password (finding 1) and dereferences `a.getDB()`
|
||
at `providers.go:654` without a nil check, panicking rather than erroring when no
|
||
database is configured.
|
||
|
||
### Remediation
|
||
|
||
Pick one of two honest outcomes.
|
||
|
||
**Implement it.** Add `github.com/golang-jwt/jwt/v5`, sign with RS256 using a
|
||
configured key (the OAuth server already manages one — `oauth_server.go:140`,
|
||
`:175-197` — and exposes JWKS at `:343`, so reuse that key and key ID rather than
|
||
introducing a second):
|
||
|
||
```go
|
||
claims := jwt.MapClaims{
|
||
"sub": strconv.Itoa(user.ID), "iss": a.issuer, "aud": a.audience,
|
||
"iat": now.Unix(), "exp": now.Add(a.ttl).Unix(), "jti": newJTI(),
|
||
"roles": user.Roles,
|
||
}
|
||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||
signed, err := tok.SignedString(a.signingKey)
|
||
```
|
||
|
||
and in `Authenticate` parse with an explicit algorithm allow-list
|
||
(`jwt.WithValidMethods([]string{"RS256"})`) — never trust the token's own `alg` —
|
||
validating `iss`, `aud` and `exp`, then check `jti` against the blacklist table
|
||
`jwtLogoutDirect` already writes.
|
||
|
||
**Or remove it.** If JWT is not a supported mode, delete `JWTAuthenticator`
|
||
rather than shipping a type whose `Login` hands out forgeable credentials. A
|
||
constructor that returns `nil, errors.New("JWT authentication is not
|
||
implemented")` is safer than one that returns a working-looking token.
|
||
|
||
Either way, do not leave `Login` issuing `token_<id>_<ts>`. If the type must stay
|
||
as a stub, make `Login` fail the same way `Authenticate` does.
|
||
|
||
---
|
||
|
||
## 34. High — a persisted client is rehydrated without its secret, so confidential clients become public
|
||
|
||
The token endpoint gates client authentication on whether the client has a stored
|
||
secret:
|
||
|
||
```go
|
||
// oauth_server.go:77-79
|
||
func (c *oauthClient) isConfidential() bool {
|
||
return c.ClientSecretHash != ""
|
||
}
|
||
```
|
||
|
||
```go
|
||
// oauth_server.go:805-812
|
||
// Confidential clients (those registered with a client_secret) must authenticate;
|
||
// public clients keep relying on PKCE alone, unchanged from prior behavior.
|
||
if client, ok := s.lookupOrFetchClient(r.Context(), clientID); ok && client.isConfidential() {
|
||
if _, err := s.authenticateClient(r); err != nil {
|
||
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
|
||
return
|
||
}
|
||
}
|
||
```
|
||
|
||
Registration stores the hash correctly — `ClientSecretHash: secretHash` at
|
||
`:468`, persisted through `OAuthRegisterClient` at `:472-486` — and the in-memory
|
||
copy in `s.clients` carries it. But the read-through path does not:
|
||
|
||
```go
|
||
// oauth_server.go:1108-1135
|
||
// lookupOrFetchClient checks in-memory first, then DB if PersistClients is enabled.
|
||
func (s *OAuthServer) lookupOrFetchClient(ctx context.Context, clientID string) (*oauthClient, bool) {
|
||
s.mu.RLock()
|
||
c, ok := s.clients[clientID]
|
||
s.mu.RUnlock()
|
||
if ok {
|
||
return c, true
|
||
}
|
||
|
||
if !s.cfg.PersistClients || s.auth == nil {
|
||
return nil, false
|
||
}
|
||
|
||
dbClient, err := s.auth.OAuthGetClient(ctx, clientID)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
|
||
c = &oauthClient{
|
||
ClientID: dbClient.ClientID,
|
||
RedirectURIs: dbClient.RedirectURIs,
|
||
ClientName: dbClient.ClientName,
|
||
GrantTypes: dbClient.GrantTypes,
|
||
AllowedScopes: dbClient.AllowedScopes,
|
||
}
|
||
s.mu.Lock()
|
||
s.clients[clientID] = c
|
||
s.mu.Unlock()
|
||
return c, true
|
||
}
|
||
```
|
||
|
||
`ClientSecretHash` and `TokenEndpointAuthMethod` are **not copied**, even though
|
||
`OAuthServerClient` carries both (`oauth_server_db.go:11-19`) and the registration
|
||
path writes both (`:479-480`). So the rehydrated client has an empty hash, `isConfidential()` returns
|
||
false, and the guard at `:807` skips client authentication entirely. The incomplete
|
||
record is then written back into `s.clients` (`:1134`), so the wrong answer is
|
||
cached for the remaining life of the process.
|
||
|
||
This triggers whenever the in-memory map does not already hold the client:
|
||
|
||
* **after any restart or deploy** — every confidential client registered before the
|
||
restart is treated as public from then on;
|
||
* **on every replica that did not handle the registration** — so in a
|
||
multi-instance deployment it is the normal case, not the exceptional one;
|
||
* **after the first eviction**, if the pruning recommended in finding 11 is added
|
||
without fixing this.
|
||
|
||
The practical effect is that a confidential client's `client_secret` stops being
|
||
required at the token endpoint. Two mitigations keep this at High rather than
|
||
Critical. PKCE is mandatory for every client — `code_challenge` is required at the
|
||
authorize endpoint (`:549-551`) and the verifier is checked on both the persisted
|
||
and in-memory code paths (`:831`, `:859`) — so an attacker still needs the
|
||
`code_verifier` that the legitimate client generated. And the code is bound to its
|
||
`client_id` and `redirect_uri` (`:851-857`). What is lost is the independent control
|
||
RFC 6749 §3.2.1 requires for confidential clients: possession of the secret. An
|
||
attacker who obtains a code through any channel that does not also yield the
|
||
verifier — a logged query string, a referrer leak, an open redirector on a
|
||
registered URI — no longer faces the secret as a second barrier.
|
||
|
||
There is a matching availability bug in the same defect: a *legitimate* confidential
|
||
client that presents the correct secret after a restart is rejected, because
|
||
`authenticateClient` refuses a client that is not confidential
|
||
(`:1255-1258`). It does not surface, only because `:807` never calls it.
|
||
|
||
### Remediation
|
||
|
||
Copy the whole record:
|
||
|
||
```go
|
||
c = &oauthClient{
|
||
ClientID: dbClient.ClientID,
|
||
RedirectURIs: dbClient.RedirectURIs,
|
||
ClientName: dbClient.ClientName,
|
||
GrantTypes: dbClient.GrantTypes,
|
||
AllowedScopes: dbClient.AllowedScopes,
|
||
ClientSecretHash: dbClient.ClientSecretHash,
|
||
TokenEndpointAuthMethod: dbClient.TokenEndpointAuthMethod,
|
||
}
|
||
```
|
||
|
||
Then make the omission structurally impossible and the gate fail closed:
|
||
|
||
1. **Convert, don't hand-copy.** Give `OAuthServerClient` a
|
||
`func (c *OAuthServerClient) toOAuthClient() *oauthClient` used by both the
|
||
registration and the fetch path, so a field added to one is added to both. Field
|
||
lists duplicated across a persistence boundary drift; this one already has.
|
||
2. **Do not infer confidentiality from a field that can be silently empty.**
|
||
Persist `TokenEndpointAuthMethod` and decide from it:
|
||
```go
|
||
func (c *oauthClient) isConfidential() bool {
|
||
return c.TokenEndpointAuthMethod != "" && c.TokenEndpointAuthMethod != "none"
|
||
}
|
||
```
|
||
and refuse to serve a client whose method requires a secret but whose hash is
|
||
empty, rather than downgrading it:
|
||
```go
|
||
if c.isConfidential() && c.ClientSecretHash == "" {
|
||
logger.Error("client %s loaded without a secret hash; refusing", c.ClientID)
|
||
return nil, false
|
||
}
|
||
```
|
||
3. **Add the regression test that would have caught it:** register a confidential
|
||
client with `PersistClients` enabled, construct a *new* `OAuthServer` over the
|
||
same authenticator, and assert that a code exchange without
|
||
`client_secret` is rejected with `invalid_client`. The existing OAuth tests all
|
||
run against a single in-process server, which is why the hydration path is
|
||
untested.
|
||
|
||
Fix this together with finding 11: registration and rehydration are the two halves
|
||
of one record's lifecycle, and finding 11's pruning recommendation makes this path
|
||
hot rather than restart-only.
|
||
|
||
---
|
||
|
||
## 13. Medium — the refresh-token grant authenticates no client
|
||
|
||
```go
|
||
// oauth_server.go:871-900 (abridged)
|
||
func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) {
|
||
refreshToken := r.FormValue("refresh_token")
|
||
providerName := r.FormValue("provider")
|
||
clientID := r.FormValue("client_id")
|
||
if refreshToken == "" {
|
||
writeOAuthError(w, "invalid_request", "refresh_token required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Try external providers first, then fall back to DatabaseAuthenticator
|
||
provider := s.providerByName(providerName)
|
||
if provider != nil {
|
||
loginResp, err := provider.auth.OAuth2RefreshToken(r.Context(), refreshToken, providerName)
|
||
...
|
||
s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false)
|
||
return
|
||
}
|
||
|
||
if s.auth != nil {
|
||
loginResp, err := s.auth.RefreshToken(r.Context(), refreshToken)
|
||
...
|
||
```
|
||
|
||
Compare the authorization-code grant, which does authenticate confidential
|
||
clients:
|
||
|
||
```go
|
||
// oauth_server.go:805-812
|
||
// Confidential clients (those registered with a client_secret) must authenticate;
|
||
// public clients keep relying on PKCE alone, unchanged from prior behavior.
|
||
if client, ok := s.lookupOrFetchClient(r.Context(), clientID); ok && client.isConfidential() {
|
||
if _, err := s.authenticateClient(r); err != nil {
|
||
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
|
||
return
|
||
}
|
||
}
|
||
```
|
||
|
||
The refresh path has no equivalent. `clientID` is read from the form and used only
|
||
as a pass-through to `writeOAuthToken`; it is never looked up, never authenticated,
|
||
and never compared against the client the refresh token was issued to. RFC 6749
|
||
§6 requires client authentication for confidential clients on refresh, and §10.4
|
||
requires the refresh token be bound to the client it was issued to.
|
||
|
||
Consequence: a refresh token that leaks — from a log, a referrer, a compromised
|
||
public client — can be redeemed by anyone, including as though it belonged to a
|
||
different (confidential) client, yielding a fresh access token and a fresh refresh
|
||
token. There is also no rotation check: `writeOAuthToken` is handed whatever the
|
||
underlying authenticator returns, so a replayed refresh token is not detected.
|
||
|
||
Two smaller issues in the same function: `providerName` comes from the form and
|
||
selects which external provider handles the token (`:881`), so a caller chooses
|
||
the validation path for a credential; and `err.Error()` from the provider is
|
||
echoed to the client at `:885` and `:895` (finding 18).
|
||
|
||
### Remediation
|
||
|
||
```go
|
||
func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) {
|
||
refreshToken := r.FormValue("refresh_token")
|
||
clientID := r.FormValue("client_id")
|
||
if refreshToken == "" || clientID == "" {
|
||
writeOAuthError(w, "invalid_request", "refresh_token and client_id required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
client, ok := s.lookupOrFetchClient(r.Context(), clientID)
|
||
if !ok {
|
||
writeOAuthError(w, "invalid_client", "", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if client.isConfidential() {
|
||
if _, err := s.authenticateClient(r); err != nil {
|
||
writeOAuthError(w, "invalid_client", "", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
}
|
||
// … and verify the stored refresh token's client_id == clientID before use
|
||
```
|
||
|
||
That last line needs storage support: the refresh token must record its issuing
|
||
client. `OAuthExchangeCode` already persists `ClientID` alongside a code
|
||
(`:824`), so the same shape applies.
|
||
|
||
Also rotate on use — issue a new refresh token, invalidate the old one, and treat
|
||
reuse of an invalidated token as a compromise signal (RFC 6749 §10.4 / OAuth 2.1
|
||
§6.1). And bind the provider to the token rather than to a form field: store
|
||
`ProviderName` with the token and ignore `r.FormValue("provider")`.
|
||
|
||
---
|
||
|
||
## 14. Medium — both security maps grow without bound, keyed on tokens and claims
|
||
|
||
```go
|
||
// provider.go:54-61
|
||
type SecurityList struct {
|
||
provider SecurityProvider
|
||
|
||
ColumnSecurityMutex sync.RWMutex
|
||
ColumnSecurity map[string][]ColumnSecurity
|
||
RowSecurityMutex sync.RWMutex
|
||
RowSecurity map[string]RowSecurity
|
||
}
|
||
```
|
||
|
||
Entries are inserted at `provider.go:393` and `:438` and **never removed**. There
|
||
is no TTL, no size cap and no eviction; `ClearSecurity` (`:397-417`) is the only
|
||
delete path and it has zero callers (finding 29) and does not touch `RowSecurity`
|
||
at all.
|
||
|
||
Growth is driven by the key. Column security keys on the integer user ID:
|
||
|
||
```go
|
||
// provider.go:381
|
||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
||
```
|
||
|
||
so that map is bounded by `users × tables` — large but finite. Row security keys
|
||
on the opaque ref:
|
||
|
||
```go
|
||
// provider.go:430
|
||
secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)
|
||
```
|
||
|
||
and `pUserRef` is the whole `*UserContext` for every spec adapter
|
||
(`restheadspec/security_hooks.go:83-89`). `%v` on a pointer-to-struct renders
|
||
every field, so the key is a string like
|
||
|
||
```
|
||
public.orders@&{1 alice 0 sess_9f3c… 42 10.0.0.7 [user] alice@example.com map[email:… exp:1.76e+09 sub:…] map[] false 0 }
|
||
```
|
||
|
||
Three consequences:
|
||
|
||
* **Unbounded cardinality when claims vary.** Any field that changes per session
|
||
mints a new permanent entry. `SessionID` alone guarantees this: a user who logs
|
||
in twice has two keys; a user who logs in daily for a year has 365. With
|
||
external OIDC providers, `Claims` is the entire userinfo map
|
||
(`oauth2_methods.go:356`), so a refreshed `exp` or `iat` does the same. Map
|
||
iteration order does not matter here — `fmt` prints map keys sorted since Go
|
||
1.12 — but the values themselves vary, and that is enough.
|
||
* **Credentials held in memory indefinitely.** Each key string contains a session
|
||
token and the user's email. They persist for the process lifetime, survive
|
||
logout, and appear in any heap dump or core file.
|
||
* **Nothing is ever read from these maps anyway** — finding 6 shows both loaders
|
||
query the provider unconditionally, so this is pure accumulation with no
|
||
benefit.
|
||
|
||
### Remediation
|
||
|
||
The key and the lifetime both need fixing.
|
||
|
||
1. **Key on a stable scalar.** Row security needs a *user identity*, not a
|
||
snapshot of the request. Derive it explicitly:
|
||
```go
|
||
func securityKey(schema, table string, ref any) string {
|
||
switch v := ref.(type) {
|
||
case *UserContext:
|
||
return fmt.Sprintf("%s.%s@%d", schema, table, v.UserID) // or v.Claims["sub"]
|
||
case int:
|
||
return fmt.Sprintf("%s.%s@%d", schema, table, v)
|
||
case string:
|
||
return fmt.Sprintf("%s.%s@%s", schema, table, v)
|
||
default:
|
||
return fmt.Sprintf("%s.%s@%v", schema, table, v)
|
||
}
|
||
}
|
||
```
|
||
For deployments with non-integer identities, read the subject claim — that is
|
||
what `GetUserRef`'s doc comment (`hooks.go:17-21`) intends. A redacting
|
||
`String()` on `UserContext` (finding 7) also fixes the default branch.
|
||
2. **Bound the maps.** Once finding 6 introduces real caching, the entries need a
|
||
TTL and a cap. `pkg/cache` provides both (`MaxSize` with LRU eviction,
|
||
`cache/provider_memory.go:105-109`, `:307-326`) plus tag-based invalidation, which
|
||
would let a rule change invalidate `table:orders` across all users at once.
|
||
3. **Extend `ClearSecurity` to `RowSecurity`** and call it on logout and on rule
|
||
changes, or delete it (finding 29) once a TTL makes it redundant.
|
||
|
||
---
|
||
|
||
## 15. Medium — model rules fail open, and any authenticated user may do anything
|
||
|
||
Two layers of authorisation both default to "allow".
|
||
|
||
**Unregistered models allow writes.** `checkModelUpdateAllowed` and
|
||
`checkModelDeleteAllowed` are the `BeforeUpdate`/`BeforeDelete` hooks
|
||
(`restheadspec/security_hooks.go:49-58`):
|
||
|
||
```go
|
||
// hooks.go:274-294
|
||
func checkModelUpdateAllowed(secCtx SecurityContext) error {
|
||
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
|
||
if !ok {
|
||
schema := secCtx.GetSchema()
|
||
entity := secCtx.GetEntity()
|
||
var err error
|
||
if schema != "" {
|
||
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
||
}
|
||
if err != nil || schema == "" {
|
||
rules, err = modelregistry.GetModelRulesByName(entity)
|
||
}
|
||
if err != nil {
|
||
return nil // model not registered, allow by default
|
||
}
|
||
}
|
||
if !rules.CanUpdate {
|
||
return fmt.Errorf("update not allowed for %s", secCtx.GetEntity())
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
`checkModelDeleteAllowed` is identical (`:298-318`, fail-open at `:311`). A model
|
||
served by the spec handler but absent from the registry — or present under a name
|
||
the two lookups do not produce — is fully writable. This is the consuming side of
|
||
`modelregistry.audit.md` finding 1: the registry's lookup failure and this
|
||
`return nil` combine into "unknown model ⇒ permitted".
|
||
|
||
**Any authenticated user may perform any operation.** `CheckModelAuthAllowed` is
|
||
the `BeforeHandle` hook (`restheadspec/security_hooks.go:14-22`), and its final
|
||
step is:
|
||
|
||
```go
|
||
// hooks.go:356-365
|
||
if operation == "delete" && rules.CanPublicDelete {
|
||
return nil
|
||
}
|
||
|
||
userID, _ := secCtx.GetUserID()
|
||
if userID == 0 {
|
||
return fmt.Errorf("authentication required")
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
The only distinction it draws is authenticated vs. guest. `UserContext.Roles` and
|
||
`UserLevel` are populated by every authenticator and consulted nowhere in this
|
||
function. The doc comment is candid about it — *"8. Authenticated user → allow
|
||
(operation-specific checks remain in BeforeUpdate/BeforeDelete)"* (`:332`) — but
|
||
`BeforeUpdate`/`BeforeDelete` check only the model's global `CanUpdate`/`CanDelete`
|
||
flags, which are per-model, not per-role. So `rules.CanUpdate == true` means
|
||
*every* logged-in user may update *every* row, subject only to row security —
|
||
which finding 2 shows is inert. Read and create have no per-operation check at all.
|
||
|
||
Note also that `userID == 0` is the guest marker (`middleware.go:46`), so a real
|
||
user whose ID is genuinely 0 is treated as unauthenticated, and conversely any
|
||
authenticator that fails to populate `UserIDKey` yields `userID == 0` from the
|
||
ignored-error `GetUserID` at `:360` and is correctly denied — the one place the
|
||
fail-closed direction holds.
|
||
|
||
### Remediation
|
||
|
||
1. **Fail closed on unknown models.** An operation on a model with no rules should
|
||
be refused, not permitted:
|
||
```go
|
||
rules, ok := resolveModelRules(secCtx)
|
||
if !ok {
|
||
return fmt.Errorf("no security rules registered for %s.%s", secCtx.GetSchema(), secCtx.GetEntity())
|
||
}
|
||
```
|
||
If that is too strict for existing deployments, make it configurable and
|
||
default to closed — with a startup log listing every served model that has no
|
||
rules, so the gap is visible at deploy time rather than at exploit time.
|
||
`resolveModelRules` (`:369-390`) already centralises the lookup, so the change
|
||
is one place plus the two write checks.
|
||
2. **Add role checks.** `ModelRules` needs per-operation role requirements —
|
||
`ReadRoles`, `CreateRoles`, `UpdateRoles`, `DeleteRoles` — and
|
||
`CheckModelAuthAllowed` should require intersection with `userCtx.Roles`:
|
||
```go
|
||
required := rules.RolesFor(operation)
|
||
if len(required) > 0 && !hasAnyRole(userCtx.Roles, required) {
|
||
return fmt.Errorf("operation %s on %s requires one of %v", operation, entity, required)
|
||
}
|
||
```
|
||
Until that exists, the package's authorisation model is "logged in ⇒ trusted",
|
||
and row security is the only tenant boundary — which makes finding 2 the whole
|
||
of the access-control story.
|
||
3. `GetModelRulesFromContext` is populated by `NewModelAuthMiddleware`
|
||
(`middleware.go:225`), which is per-model. Verify that every route that reaches
|
||
these hooks goes through it; where it does not, the fallback registry lookup at
|
||
`:277-288` is the only path and its two name forms must both be correct.
|
||
|
||
---
|
||
|
||
## 16. Medium — there is no audit log
|
||
|
||
`logDataAccess` is registered as an `AfterRead` hook by every spec
|
||
(`restheadspec/security_hooks.go:43-46`) and is this:
|
||
|
||
```go
|
||
// hooks.go:198-218
|
||
func logDataAccess(secCtx SecurityContext) error {
|
||
userID, _ := secCtx.GetUserID()
|
||
|
||
logger.Info("AUDIT: User %d accessed %s.%s",
|
||
userID,
|
||
secCtx.GetSchema(),
|
||
secCtx.GetEntity(),
|
||
)
|
||
|
||
// TODO: Write to audit log table or external audit service
|
||
// auditLog := AuditLog{
|
||
// UserID: userID,
|
||
// Schema: secCtx.GetSchema(),
|
||
// Entity: secCtx.GetEntity(),
|
||
// Action: "READ",
|
||
// Timestamp: time.Now(),
|
||
// }
|
||
// db.Create(&auditLog)
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
What this is: a log line. What it is not, and what a control named `AUDIT` implies:
|
||
durable, queryable, tamper-evident, and complete. Specifically —
|
||
|
||
* It records reads only. There is no equivalent hook on create, update or delete,
|
||
so the operations that change data are not audited at all.
|
||
* It records no row identity, no filter, no column list and no result count, so it
|
||
cannot answer "which records did this user see?" — the question an audit log
|
||
exists to answer.
|
||
* It goes to the application log, which rotates and is not tamper-evident.
|
||
* `Info` is not forwarded to the error tracker (`logger/logger.go:100-106`), so
|
||
it exists in exactly one place, on the host.
|
||
* It fires on the read path of every request, so it doubles log volume for no
|
||
retrievable benefit.
|
||
|
||
For any deployment with a compliance obligation (SOC 2 CC7, PCI DSS 10, HIPAA
|
||
§164.312(b)), this does not satisfy it, and the `AUDIT:` prefix makes it look as
|
||
though it might.
|
||
|
||
### Remediation
|
||
|
||
Either build it or stop calling it an audit log.
|
||
|
||
To build it: write to a dedicated append-only table through the same
|
||
`SecurityProvider` abstraction, and cover writes as well as reads:
|
||
|
||
```go
|
||
type AuditEvent struct {
|
||
At time.Time
|
||
UserID int
|
||
SessionID string // hash, not the token — see finding 7
|
||
Operation string // read | create | update | delete
|
||
Schema string
|
||
Entity string
|
||
RecordIDs []string // primary keys touched
|
||
Filters string // normalised, no values
|
||
RowCount int
|
||
RequestID string
|
||
RemoteAddr string
|
||
Outcome string // allowed | denied
|
||
}
|
||
|
||
type AuditSink interface { Record(ctx context.Context, e AuditEvent) error }
|
||
```
|
||
|
||
Register it on `AfterRead`, `AfterCreate`, `AfterUpdate`, `AfterDelete` **and** on
|
||
the deny paths (`hooks.go:94`, `:291`, `:315`, `:339`, `:362`) — a denied attempt
|
||
is the event most worth recording. Write asynchronously through a bounded queue so
|
||
the sink cannot become the throughput ceiling the way finding 6's mutexes are, and
|
||
decide explicitly whether a full queue drops events or fails the request; for a
|
||
compliance audit log it must be the latter.
|
||
|
||
If building it is out of scope, rename the function and drop the `AUDIT:` prefix so
|
||
nothing reads as a control that is not one, and say plainly in
|
||
`SECURITY_FEATURES.md` that audit logging is the integrator's responsibility.
|
||
|
||
---
|
||
|
||
## 17. Medium — identifier validation exists but is never called
|
||
|
||
The package interpolates configurable names into SQL in dozens of places:
|
||
|
||
```go
|
||
// providers.go:413
|
||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user::text FROM %s($1, $2)`, a.sqlNames.Session)
|
||
```
|
||
|
||
```go
|
||
// providers_direct.go:216-220
|
||
query := rewritePlaceholders(db, fmt.Sprintf(
|
||
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, u.program_user_id, u.program_user_table
|
||
FROM %s s JOIN %s u ON s.user_id = u.id
|
||
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
||
a.tableNames.UserSessions, a.tableNames.Users))
|
||
```
|
||
|
||
and validators for exactly this exist:
|
||
|
||
```go
|
||
// sql_names.go:9
|
||
var validSQLIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||
```
|
||
|
||
```go
|
||
// sql_names.go:242-258
|
||
// ValidateSQLNames checks that all non-empty fields in names are valid SQL identifiers.
|
||
// Returns an error if any field contains invalid characters.
|
||
func ValidateSQLNames(names *SQLNames) error {
|
||
v := reflect.ValueOf(names).Elem()
|
||
...
|
||
if val != "" && !validSQLIdentifier.MatchString(val) {
|
||
return fmt.Errorf("SQLNames.%s contains invalid characters: %q", typ.Field(i).Name, val)
|
||
}
|
||
```
|
||
|
||
Grepping the whole repository for `ValidateSQLNames` and `ValidateTableNames`
|
||
returns their own definitions and their tests — **no production caller**. The
|
||
resolvers that every constructor goes through skip them:
|
||
|
||
```go
|
||
// sql_names.go:262-267
|
||
func resolveSQLNames(override ...*SQLNames) *SQLNames {
|
||
if len(override) > 0 && override[0] != nil {
|
||
return MergeSQLNames(DefaultSQLNames(), override[0])
|
||
}
|
||
return DefaultSQLNames()
|
||
}
|
||
```
|
||
|
||
```go
|
||
// table_names.go:99-101
|
||
func resolveTableNames(override *TableNames) *TableNames {
|
||
return MergeTableNames(DefaultTableNames(), override)
|
||
}
|
||
```
|
||
|
||
So a `SQLNames`/`TableNames` override flows unvalidated into `fmt.Sprintf`. These
|
||
values come from application configuration, not from a request, which caps the
|
||
severity — this is not remote SQL injection. But it is injection-by-configuration:
|
||
a name assembled from an environment variable or a tenant identifier becomes SQL,
|
||
and the guard written to prevent it is inert. This is another instance of
|
||
`_CROSS-CUTTING` finding X10 — a control that is implemented, tested, and never
|
||
installed.
|
||
|
||
Note also that the regex rejects schema-qualified names: `public.users` fails
|
||
`^[a-zA-Z_][a-zA-Z0-9_]*$`. Since validation never runs, deployments using
|
||
`"myschema.users"` work today and would break the moment it is wired in — so the
|
||
fix needs the regex widened (or a per-part check) at the same time.
|
||
|
||
### Remediation
|
||
|
||
Call the validators where the names enter, and fail at construction rather than at
|
||
query time:
|
||
|
||
```go
|
||
func resolveSQLNames(override ...*SQLNames) (*SQLNames, error) {
|
||
n := DefaultSQLNames()
|
||
if len(override) > 0 && override[0] != nil {
|
||
n = MergeSQLNames(n, override[0])
|
||
}
|
||
if err := ValidateSQLNames(n); err != nil {
|
||
return nil, err
|
||
}
|
||
return n, nil
|
||
}
|
||
```
|
||
|
||
That changes the signature of the constructors that call it
|
||
(`NewDatabaseAuthenticatorWithOptions`, `NewDatabaseColumnSecurityProvider` at
|
||
`providers.go:771`, and the row-security and keystore equivalents). Several
|
||
already return only a value; those become `(T, error)`. Rejecting a bad
|
||
configuration at startup is worth the churn.
|
||
|
||
Widen the identifier rule to allow an optional schema qualifier, validating each
|
||
part:
|
||
|
||
```go
|
||
var validSQLIdentifierPart = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||
|
||
func validIdentifier(s string) bool {
|
||
parts := strings.Split(s, ".")
|
||
if len(parts) > 2 { return false }
|
||
for _, p := range parts {
|
||
if !validSQLIdentifierPart.MatchString(p) { return false }
|
||
}
|
||
return true
|
||
}
|
||
```
|
||
|
||
and quote on interpolation (`pq.QuoteIdentifier` or the dialect equivalent) so the
|
||
regex is defence in depth rather than the only line.
|
||
|
||
---
|
||
|
||
## 18. Medium — internal error text is returned to unauthenticated clients
|
||
|
||
```go
|
||
// middleware.go:82-91
|
||
func authenticateRequest(w http.ResponseWriter, r *http.Request, provider SecurityProvider) (*http.Request, bool) {
|
||
// Call the provider's Authenticate method
|
||
userCtx, err := provider.Authenticate(r)
|
||
if err != nil {
|
||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
```
|
||
|
||
The same pattern at `middleware.go:203` (`NewAuthMiddleware`) and `:266`
|
||
(`NewModelAuthMiddleware`), and in the OAuth server at `oauth_server.go:809`,
|
||
`:885` and `:895`.
|
||
|
||
`err` is whatever the provider produced, and the providers wrap freely:
|
||
|
||
* `"session query failed: %w"` (`providers.go:417`) — carries the driver's error,
|
||
so a malformed query or a missing procedure returns the PostgreSQL message,
|
||
including the procedure name and sometimes the SQL.
|
||
* `"%s", errorMsg.String` (`providers.go:422`) — the stored procedure's own text,
|
||
verbatim, to the client.
|
||
* `"failed to parse user context: %w"` (`:434`) — a JSON error quoting the
|
||
offending input, which is the session payload.
|
||
* `"invalid user ID: %w"` (`:53`) — `strconv`'s message including the value.
|
||
|
||
An unauthenticated attacker learns the database dialect, the procedure naming
|
||
scheme, whether a given procedure exists, and whether a failure was "no such
|
||
session" versus "database unreachable" — a clean oracle for probing
|
||
`SQLNames`/`TableNames` configuration and for distinguishing valid from invalid
|
||
tokens.
|
||
|
||
### Remediation
|
||
|
||
Separate what the client is told from what is recorded:
|
||
|
||
```go
|
||
func authenticateRequest(w http.ResponseWriter, r *http.Request, provider SecurityProvider) (*http.Request, bool) {
|
||
userCtx, err := provider.Authenticate(r)
|
||
if err != nil {
|
||
logger.Debug("authentication failed for %s %s: %v", r.Method, r.URL.Path, err)
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
if userCtx == nil {
|
||
logger.Error("authenticator returned nil user context with nil error")
|
||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||
return nil, false
|
||
}
|
||
return setUserContext(r, userCtx), true
|
||
}
|
||
```
|
||
|
||
`Debug` rather than `Warn` on the log side is deliberate: failed authentication is
|
||
attacker-triggerable, so at `Warn` it becomes a Sentry flood (X8). Attach a
|
||
request ID to the log line and return it in the response if operators need to
|
||
correlate a specific 401.
|
||
|
||
Apply the same shape at `middleware.go:203`, `:266`, and to the OAuth handlers —
|
||
`writeOAuthError` should send the RFC 6749 error code (`invalid_client`,
|
||
`invalid_grant`) with an empty or fixed `error_description`, never `err.Error()`.
|
||
`oauth_server.go:825` and `:852` already do exactly that (`writeOAuthError(w,
|
||
"invalid_client", "", …)`), so the correct pattern is already in the file; `:809`,
|
||
`:885` and `:895` are the outliers.
|
||
|
||
The `userCtx == nil` guard also closes finding 32.
|
||
|
||
---
|
||
|
||
## 19. Medium — a panicking driver silently downgrades the authenticator to Direct mode
|
||
|
||
```go
|
||
// query_mode.go:53-72
|
||
// probeFunctionExists checks, via a Postgres-specific system catalog query,
|
||
// whether a function named procName exists. Any error (wrong dialect,
|
||
// placeholder syntax rejected, relation missing, etc.) is treated as "does
|
||
// not exist" rather than propagated - the probe must never be able to panic
|
||
// or block resolution of the query mode.
|
||
func probeFunctionExists(ctx context.Context, db *sql.DB, procName string) bool {
|
||
if db == nil {
|
||
return false
|
||
}
|
||
var exists bool
|
||
defer func() {
|
||
// Guard against any unexpected panic from a misbehaving driver.
|
||
_ = recover()
|
||
}()
|
||
row := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, procName)
|
||
if err := row.Scan(&exists); err != nil {
|
||
return false
|
||
}
|
||
return exists
|
||
}
|
||
```
|
||
|
||
The result is unnamed, so the recovered panic makes the function return `false` —
|
||
and `false` is not a neutral value here. It means "the stored procedure does not
|
||
exist", which makes `ShouldUseProcedure` select **Direct mode**
|
||
(`providers.go:404`, `:483`, `:211`, `:250`, and every other call site). Direct
|
||
mode is the code path documented as never verifying passwords
|
||
(`providers_direct.go:20-23`).
|
||
|
||
So a transient fault in the driver silently switches the whole authenticator from
|
||
the stored-procedure implementation to the one with weaker behaviour, with no log
|
||
line at all — `_ = recover()` discards the value. The same applies to the
|
||
ordinary error path at `:68`: a permissions error reading `pg_proc`, or a
|
||
connection blip during the probe, is indistinguishable from "procedure absent".
|
||
|
||
The result is cached in a `sync.Map` (`query_mode.go:36`) and only cleared by
|
||
`reset()` (`:46-51`), so one bad probe pins the downgraded mode until a reconnect.
|
||
|
||
The intent — "the probe must never be able to panic or block resolution" — is
|
||
sound. The problem is that the fallback direction is the less safe one, and the
|
||
event is invisible.
|
||
|
||
### Remediation
|
||
|
||
1. **Log it.** A discarded panic in security-relevant code is never acceptable:
|
||
```go
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
logger.Error("probeFunctionExists(%s) panicked: %v", procName, r)
|
||
}
|
||
}()
|
||
```
|
||
Same for the `Scan` error at `:68` — at `Debug` if it is expected on non-Postgres
|
||
dialects, but not silent.
|
||
2. **Do not cache a failed probe.** Distinguish the three outcomes rather than
|
||
collapsing them into a bool:
|
||
```go
|
||
type probeResult int
|
||
const (probeAbsent probeResult = iota; probePresent; probeUnknown)
|
||
```
|
||
Cache `probeAbsent`/`probePresent`; retry on `probeUnknown`.
|
||
3. **Make the mode explicit in production.** `ModeAuto` is convenient for tests and
|
||
ambiguous in deployment. Recommend — and document — setting
|
||
`QueryMode: ModeProcedure` explicitly where the procedures are installed, so a
|
||
probe failure produces an error instead of a silent behaviour change.
|
||
4. Log the resolved mode once at startup, per procedure name. "Using Direct mode
|
||
for resolvespec_login" is exactly the line an operator needs and it does not
|
||
exist today.
|
||
|
||
Most of this becomes moot once finding 1 is fixed and both modes verify passwords
|
||
— which is the real answer: the two modes should not differ in their security
|
||
properties, so that choosing between them cannot be a security event.
|
||
|
||
---
|
||
|
||
## 20. Medium — a fire-and-forget goroutine uses the request's context
|
||
|
||
```go
|
||
// providers.go:445-449
|
||
// Update last activity asynchronously (don't block the request)
|
||
if userCtx != nil {
|
||
go a.updateSessionActivity(r.Context(), token)
|
||
}
|
||
return userCtx, nil
|
||
```
|
||
|
||
Two problems in one line.
|
||
|
||
**The context dies with the request.** `r.Context()` is cancelled when the handler
|
||
returns, which is typically before the goroutine gets scheduled. So
|
||
`updateSessionActivity`'s `ExecContext` (`:482-502`) is racing the response: some
|
||
fraction of these writes fail with `context canceled`, and which fraction depends
|
||
on load. The intent is clearly the opposite of what the code does — the comment
|
||
says "don't block the request", but detaching the work from the request also
|
||
detaches it from the request's lifetime. `context.WithoutCancel(r.Context())` (Go
|
||
1.21+) or `context.Background()` with an explicit timeout is what is wanted:
|
||
|
||
```go
|
||
if userCtx != nil {
|
||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||
go func() { defer cancel(); a.updateSessionActivity(ctx, token) }()
|
||
}
|
||
```
|
||
|
||
**The goroutine is unbounded and untracked.** One per authenticated request, with
|
||
no semaphore, no wait group, and no panic recovery. `updateSessionActivity`
|
||
performs a DB write, so under load this spawns an unbounded number of concurrent
|
||
writers against the connection pool — the pool bounds the concurrency, but the
|
||
goroutines queue behind it without limit, and each holds a token string alive.
|
||
Nothing waits for them at shutdown, so in-flight activity updates are lost on a
|
||
restart; and any panic inside the goroutine (for example from a nil `a.getDB()`)
|
||
takes the whole process down, because a panic in a goroutine cannot be recovered
|
||
by the handler.
|
||
|
||
The pattern is worth fixing structurally, not per-call-site: session activity is a
|
||
write-heavy, low-value, idempotent update — exactly the workload for coalescing.
|
||
|
||
### Remediation
|
||
|
||
1. Use a detached context with a timeout, as above.
|
||
2. Recover inside the goroutine: `defer logger.CatchPanic("updateSessionActivity")()`
|
||
— this is one of the few places `CatchPanic` is the right helper, because the
|
||
goroutine has no results and no caller to mislead (contrast finding 3).
|
||
3. Better: coalesce. Keep a small in-memory map of `token → lastSeen`, updated
|
||
under a mutex by the request path, and have **one** background goroutine flush
|
||
it every 30 seconds in a single batched `UPDATE`. That turns N writes per
|
||
second into one, bounds the goroutine count at 1, and makes the shutdown story
|
||
trivial (flush once in `Close`). It also removes the write from the
|
||
authentication hot path entirely.
|
||
4. Whichever route, register the worker with the server's shutdown sequence so the
|
||
final flush happens before the process exits — see `server.audit.md` on the
|
||
shutdown budget.
|
||
|
||
---
|
||
|
||
## 21. Medium — `cleanupStates` leaks one goroutine per OAuth2 configuration
|
||
|
||
```go
|
||
// oauth2_methods.go:74-86 (abridged)
|
||
func (a *DatabaseAuthenticator) ConfigureOAuth2Provider(name string, cfg OAuth2Config) {
|
||
a.oauth2Mutex.Lock()
|
||
defer a.oauth2Mutex.Unlock()
|
||
if a.oauth2Configs == nil {
|
||
a.oauth2Configs = make(map[string]OAuth2Config)
|
||
}
|
||
a.oauth2Configs[name] = cfg
|
||
|
||
if !a.oauth2CleanupStarted {
|
||
a.oauth2CleanupStarted = true
|
||
go a.cleanupStates()
|
||
}
|
||
}
|
||
```
|
||
|
||
and the worker:
|
||
|
||
```go
|
||
// oauth2_methods.go:337-351
|
||
func (a *DatabaseAuthenticator) cleanupStates() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
a.oauth2Mutex.Lock()
|
||
for state, entry := range a.oauth2States {
|
||
if time.Now().After(entry.ExpiresAt) {
|
||
delete(a.oauth2States, state)
|
||
}
|
||
}
|
||
a.oauth2Mutex.Unlock()
|
||
}
|
||
}
|
||
```
|
||
|
||
`for range ticker.C` with no `ctx.Done()` case and no stop channel: the goroutine
|
||
runs until the process exits. The `oauth2CleanupStarted` flag correctly prevents
|
||
more than one per `DatabaseAuthenticator`, so this is not a per-request leak — but
|
||
there is no `Close` on `DatabaseAuthenticator` at all, so every authenticator ever
|
||
constructed keeps a live goroutine and keeps its entire `oauth2States` map, its
|
||
configs (including **client secrets**) and its DB handle reachable. Tests that
|
||
construct authenticators in a loop accumulate them; a long-running process that
|
||
rebuilds its authenticator on configuration reload accumulates them too.
|
||
|
||
Contrast `OAuthServer`, which does this correctly: a `done` channel created in the
|
||
constructor (`oauth_server.go:193`), selected on in the worker (`:256-257`), and
|
||
closed by an idempotent `Close` (`:204-211`).
|
||
|
||
A related overwrite hazard sits two lines up: `a.oauth2Configs[name] = cfg` at
|
||
`:80` replaces an existing provider configuration silently. Since `providerName`
|
||
is chosen by the client on the refresh path (finding 13), a mis-keyed
|
||
reconfiguration silently redirects token validation to a different provider.
|
||
|
||
### Remediation
|
||
|
||
Give `DatabaseAuthenticator` a lifecycle, mirroring `OAuthServer`:
|
||
|
||
```go
|
||
type DatabaseAuthenticator struct {
|
||
...
|
||
stopCh chan struct{}
|
||
wg sync.WaitGroup
|
||
}
|
||
|
||
func (a *DatabaseAuthenticator) startCleanup() {
|
||
a.wg.Add(1)
|
||
go func() {
|
||
defer a.wg.Done()
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-a.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
a.pruneStates()
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (a *DatabaseAuthenticator) Close() error {
|
||
a.closeOnce.Do(func() { close(a.stopCh) })
|
||
a.wg.Wait()
|
||
return nil
|
||
}
|
||
```
|
||
|
||
Wire `Close` into the server's shutdown path, and add `defer logger.CatchPanic(…)()`
|
||
inside the worker so a panic in `pruneStates` does not kill the process.
|
||
|
||
Log an overwrite in `ConfigureOAuth2Provider` (`logger.Info`, not `Warn` —
|
||
reconfiguration is an operator action, not an error) so a duplicated provider name
|
||
is visible.
|
||
|
||
---
|
||
|
||
## 22. Medium — "no rules" is returned as an error, so a normal read is reported to Sentry
|
||
|
||
`ApplyColumnSecurity` treats an absent bucket as a failure:
|
||
|
||
```go
|
||
// provider.go:311-314
|
||
colsecList, ok := m.ColumnSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
||
if !ok || colsecList == nil {
|
||
return records, fmt.Errorf("nocolumn security data")
|
||
}
|
||
```
|
||
|
||
and the hook logs that failure at `Warn`:
|
||
|
||
```go
|
||
// hooks.go:182-187
|
||
maskedResult, err := securityList.ApplyColumnSecurity(resultValue, modelType, userID, schema, tablename)
|
||
if err != nil {
|
||
logger.Warn("Column security error: %v", err)
|
||
// Don't fail the request, just log the issue
|
||
return nil
|
||
}
|
||
```
|
||
|
||
`logger.Warn` is forwarded to the error tracker (`logger/logger.go:118-122`), so
|
||
**every read of every table that has no column-masking rules produces a Sentry
|
||
event**. The absence of rules is the normal case — most tables mask nothing — so
|
||
this is not an edge case but the steady state. At any real request rate it exhausts
|
||
the error-tracking quota and buries the events that matter; the `Warn` also cannot
|
||
be tuned away without losing the genuine failures, because both arrive with the
|
||
same level and the only distinguishing feature is the error string
|
||
`"nocolumn security data"` (note the missing space, which makes it harder to grep
|
||
for than it should be).
|
||
|
||
Two things amplify it.
|
||
|
||
**In Direct mode, this fires on every read of every table.** Both database
|
||
providers refuse to operate without the stored procedures:
|
||
|
||
```go
|
||
// providers.go:810-813
|
||
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
|
||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.ColumnSecurity) {
|
||
return nil, ErrDirectModeUnsupported
|
||
}
|
||
```
|
||
|
||
```go
|
||
// providers.go:926-929
|
||
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
|
||
return RowSecurity{}, ErrDirectModeUnsupported
|
||
}
|
||
```
|
||
|
||
`ErrDirectModeUnsupported` is explicit about the consequence — *"direct mode does
|
||
not support column/row security; requires the
|
||
resolvespec_column_security/resolvespec_row_security stored procedures"*
|
||
(`table_names.go:15`) — and it is honest, documented behaviour. But it means that
|
||
when the procedures are not installed, **all column masking and all row filtering
|
||
are unavailable**, and `loadSecurityRules` converts that into two `Warn` lines per
|
||
request:
|
||
|
||
```go
|
||
// hooks.go:46-64 (abridged)
|
||
err := securityList.LoadColumnSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
||
if err != nil {
|
||
logger.Warn("Failed to load column security: %v", err)
|
||
// Don't fail the request if no security rules exist
|
||
// return err
|
||
}
|
||
...
|
||
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
|
||
if err != nil {
|
||
logger.Warn("Failed to load row security: %v", err)
|
||
}
|
||
```
|
||
|
||
So a Direct-mode deployment emits three Sentry events per secured read — two from
|
||
the loader, one from `ApplyColumnSecurity` — while silently serving unmasked,
|
||
unfiltered data. Finding 19 makes this reachable by accident: a single panicking or
|
||
erroring `pg_proc` probe downgrades a procedure-mode deployment into exactly this
|
||
state.
|
||
|
||
**A guest request adds a fourth.** `hooks.go:36` logs `"No user ID in context for
|
||
security check"` at `Warn` and returns nil, so every unauthenticated request that
|
||
reaches the hook is also a Sentry event — and unauthenticated requests are the ones
|
||
an attacker can send without limit (`middleware.audit.md` finding 1).
|
||
|
||
The row-security path is better behaved: `hooks.go:85-89` logs its "no rules" case
|
||
at `Debug` and returns nil. That is the right level — but it is also the reason a
|
||
genuine row-security lookup failure is invisible, which is finding 5's fail-open
|
||
seen from the logging side.
|
||
|
||
### Remediation
|
||
|
||
Return the distinction in the type rather than in the error, so the caller can tell
|
||
"no rules" from "lookup failed":
|
||
|
||
```go
|
||
// ApplyColumnSecurity masks the records in place. ok is false when no column rules
|
||
// are configured for this user and table, which is not an error.
|
||
func (m *SecurityList) ApplyColumnSecurity(...) (result reflect.Value, ok bool, err error)
|
||
```
|
||
|
||
and in the hook:
|
||
|
||
```go
|
||
masked, ok, err := securityList.ApplyColumnSecurity(resultValue, modelType, userID, schema, tablename)
|
||
switch {
|
||
case err != nil:
|
||
logger.Error("column security failed for %s.%s: %v", schema, tablename, err)
|
||
return fmt.Errorf("unable to apply column security to %s.%s", schema, tablename) // fail closed
|
||
case !ok:
|
||
return nil // no rules configured: nothing to apply, nothing to log
|
||
}
|
||
if masked.IsValid() && masked.CanInterface() {
|
||
secCtx.SetResult(masked.Interface())
|
||
}
|
||
return nil
|
||
```
|
||
|
||
Two things happen at once here and both matter: the normal case stops logging, and
|
||
a genuine failure stops being ignored. That second half is finding 5 — the change
|
||
of return type is what makes failing closed expressible.
|
||
|
||
For Direct mode specifically:
|
||
|
||
1. **Decide the policy explicitly at startup, not per request.** If the provider
|
||
cannot supply rules, either refuse to start with security hooks registered, or
|
||
register a no-op provider and log the decision *once*:
|
||
```go
|
||
if errors.Is(probeErr, ErrDirectModeUnsupported) {
|
||
logger.Warn("column/row security unavailable: %v; hooks will not be registered", probeErr)
|
||
}
|
||
```
|
||
`Warn` is correct there — it is operator-facing, happens once, and is exactly
|
||
what should reach the error tracker.
|
||
2. **Make `errors.Is(err, ErrDirectModeUnsupported)` a distinct branch** in
|
||
`loadSecurityRules` so it is logged at `Debug` (or not at all, given the startup
|
||
line) rather than as a per-request warning.
|
||
3. Fix the `"nocolumn security data"` message while there.
|
||
---
|
||
|
||
## 23. Medium — the OAuth signing key is ephemeral, so tokens do not survive a restart
|
||
|
||
```go
|
||
// oauth_server.go:175-196
|
||
signingKey := cfg.SigningKey
|
||
if signingKey == nil {
|
||
var err error
|
||
signingKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||
if err != nil {
|
||
// Signing keys are only required for id_token issuance (OIDC "openid" scope);
|
||
// leaving signingKey nil degrades gracefully by omitting id_token/JWKS support.
|
||
signingKey = nil
|
||
}
|
||
}
|
||
...
|
||
if signingKey != nil {
|
||
s.signingKeyID = rsaKeyID(&signingKey.PublicKey)
|
||
}
|
||
```
|
||
|
||
The fallback is correct in construction — 2048-bit RSA from `crypto/rand`, key ID
|
||
derived from the public key (`rsaKeyID`, `:1271`) — and `cfg.SigningKey` is the
|
||
documented way to supply a stable one. Two problems follow from the default path.
|
||
|
||
First, a key-generation failure is swallowed: `signingKey = nil` with no log line
|
||
at all, and `NewOAuthServer` returns a server that silently omits `id_token` and
|
||
serves an empty JWKS. `rsa.GenerateKey` failing means `crypto/rand` failed, which is
|
||
not a condition to degrade quietly through — and the comment explains the *design*
|
||
without acknowledging that the operator is never told.
|
||
|
||
Second, and the reason this finding exists, the generated key is per process:
|
||
|
||
* **Every restart invalidates every issued token.** Clients holding valid,
|
||
unexpired access tokens get `invalid_token` after a deploy, with no way to
|
||
distinguish that from a revocation.
|
||
* **Replicas disagree.** Two instances behind a load balancer generate different
|
||
keys, so a token minted by one is rejected by the other — an intermittent,
|
||
load-balancer-dependent 401 that is painful to diagnose.
|
||
* **JWKS churns.** `/.well-known/jwks.json` (`:343`) publishes the current public
|
||
key only; a relying party that caches JWKS sees the key disappear rather than
|
||
rotate, because there is no overlap period and no second key in the set.
|
||
|
||
Registered clients (finding 11) and authorization codes share the restart problem,
|
||
though codes have `PersistCodes` (`:818`) as an opt-in remedy.
|
||
|
||
### Remediation
|
||
|
||
1. **Make the key a required configuration item in production.** Load from a file
|
||
or secret manager, and log loudly at startup when falling back to a generated
|
||
key:
|
||
```go
|
||
logger.Warn("OAuth signing key not configured; generated an ephemeral key. " +
|
||
"Tokens will not survive a restart and will not validate across replicas.")
|
||
```
|
||
(`Warn` is right here: it is not attacker-triggerable, it happens once, and it
|
||
is exactly what should reach the error tracker.)
|
||
2. **Support two keys** — a current signing key and a previous verification key —
|
||
and publish both in JWKS so rotation does not invalidate outstanding tokens.
|
||
The `keyID` derivation already supports this; the JWKS handler needs to emit a
|
||
set rather than a single entry.
|
||
3. Document the requirement next to `PersistCodes` in the OAuth section of the
|
||
README, since the two have the same "works in dev, breaks in HA" character.
|
||
|
||
---
|
||
|
||
## 24. Medium — `contains` is prefix-or-suffix, not substring, and it decides the primary key
|
||
|
||
```go
|
||
// hooks.go:404-407
|
||
func contains(s, substr string) bool {
|
||
return len(s) >= len(substr) && s[:len(substr)] == substr ||
|
||
len(s) > len(substr) && s[len(s)-len(substr):] == substr
|
||
}
|
||
```
|
||
|
||
This is `strings.HasPrefix(s, substr) || strings.HasSuffix(s, substr)`. It does not
|
||
search the middle. There are three callers, and each is wrong in a different way.
|
||
|
||
**Primary-key detection (`hooks.go:117`).** This is the consequential one, because
|
||
its output goes into a SQL `WHERE` clause:
|
||
|
||
```go
|
||
// hooks.go:112-125
|
||
pkName := "id" // default
|
||
for i := 0; i < modelType.NumField(); i++ {
|
||
field := modelType.Field(i)
|
||
if tag := field.Tag.Get("bun"); tag != "" {
|
||
// Check for primary key tag
|
||
if contains(tag, "pk") || contains(tag, "primary_key") {
|
||
if sqlName := extractSQLName(tag); sqlName != "" {
|
||
pkName = sqlName
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`pkName` is substituted into the row-security template as `{PrimaryKeyName}`
|
||
(`provider.go:44`). The tag forms in this repository are:
|
||
|
||
| bun tag | `contains(tag,"pk")` | result |
|
||
| --- | --- | --- |
|
||
| `id,pk` | true (suffix) | detected |
|
||
| `pk,autoincrement` | true (prefix) | detected |
|
||
| `id,pk,autoincrement` | **false** | missed → `pkName` stays `"id"` |
|
||
| `column:user_id,pk` | true (suffix) | detected, but `extractSQLName` returns `"column:user_id"` |
|
||
| `pkid` | true (prefix) | **false positive** — a column named `pkid` is taken as the primary key |
|
||
|
||
The two failure rows both produce a wrong `{PrimaryKeyName}` in a row-security
|
||
filter. A missed primary key silently falls back to `"id"`, which is right often
|
||
enough to hide the bug and wrong exactly where it matters — a table whose key is
|
||
`uuid` or `tenant_id` gets a filter referencing a column that may not exist
|
||
(a SQL error, surfaced through finding 22's fail-open `Warn`) or, worse, one that
|
||
exists but is not the key.
|
||
|
||
**`extractSQLName`'s two uses (`hooks.go:414`, `:417`).**
|
||
|
||
```go
|
||
// hooks.go:409-422
|
||
func extractSQLName(tag string) string {
|
||
parts := splitTag(tag, ',')
|
||
for _, part := range parts {
|
||
if part != "" && !contains(part, ":") {
|
||
return part
|
||
}
|
||
if contains(part, "column:") {
|
||
return part[7:] // Skip "column:"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
`contains(part, ":")` asks "does this part begin or end with a colon", not "does it
|
||
contain one". For `column:user_id` the colon is in the middle, so `contains` is
|
||
false, `!contains` is true, and the function returns `"column:user_id"` whole —
|
||
which then becomes `pkName`. The `column:` branch at `:417` is only reachable for a
|
||
part that *starts* with `column:`, which the branch above has already consumed, so
|
||
it is effectively dead.
|
||
|
||
This is live today: row security is inert because of finding 2, so a wrong `pkName`
|
||
has no effect yet. Fixing finding 2 without fixing this one turns a dormant
|
||
correctness bug into a live one, which is why finding 2's remediation and this one
|
||
should ship together.
|
||
|
||
### Remediation
|
||
|
||
Delete `contains`, `extractSQLName` and `splitTag`, and use the standard library
|
||
plus the helper that already parses these tags correctly
|
||
(`common/handler_utils.go:53-55`):
|
||
|
||
```go
|
||
func primaryKeyName(t reflect.Type) string {
|
||
for i := 0; i < t.NumField(); i++ {
|
||
tag := t.Field(i).Tag.Get("bun")
|
||
if tag == "" {
|
||
continue
|
||
}
|
||
opts := strings.Split(tag, ",")
|
||
isPK := false
|
||
for _, o := range opts[1:] {
|
||
if o == "pk" || o == "primary_key" {
|
||
isPK = true
|
||
break
|
||
}
|
||
}
|
||
if !isPK {
|
||
continue
|
||
}
|
||
if name := common.ExtractTagValue(tag, "column"); name != "" {
|
||
return name
|
||
}
|
||
if opts[0] != "" && !strings.Contains(opts[0], ":") {
|
||
return opts[0]
|
||
}
|
||
return strings.ToLower(t.Field(i).Name)
|
||
}
|
||
return "id"
|
||
}
|
||
```
|
||
|
||
Matching options exactly (`o == "pk"`) rather than by substring removes both the
|
||
missed-key and false-positive rows from the table above. Better still, take the
|
||
primary key from `pkg/reflection`, which already resolves it from the model
|
||
metadata rather than re-parsing tags — two implementations of tag parsing in one
|
||
repository is how they drift.
|
||
|
||
Add a test asserting `primaryKeyName` on a struct tagged `bun:"id,pk,autoincrement"`
|
||
and on one tagged `bun:"column:tenant_uuid,pk"`; both fail against the current code.
|
||
|
||
---
|
||
|
||
## 25. Medium — `maskString`'s offsets are off by one, and it masks at byte positions
|
||
|
||
```go
|
||
// provider.go:85-122 (abridged)
|
||
func maskString(pString string, maskStart, maskEnd int, maskChar string, invert bool) string {
|
||
strLen := len(pString)
|
||
middleIndex := (strLen / 2)
|
||
newStr := ""
|
||
if maskStart == 0 && maskEnd == 0 {
|
||
maskStart = strLen
|
||
maskEnd = strLen
|
||
}
|
||
...
|
||
for index, char := range pString {
|
||
if invert && index >= middleIndex-maskStart && index <= middleIndex { newStr += maskChar; continue }
|
||
if invert && index <= middleIndex+maskEnd && index >= middleIndex { newStr += maskChar; continue }
|
||
if !invert && index <= maskStart { newStr += maskChar; continue }
|
||
if !invert && index >= strLen-1-maskEnd { newStr += maskChar; continue }
|
||
newStr += string(char)
|
||
}
|
||
return newStr
|
||
}
|
||
```
|
||
|
||
Called from `setColSecValue` for string fields (`provider.go:275`) and for JSON
|
||
sub-fields (`:289`), with `colsec.MaskStart`, `colsec.MaskEnd`, `colsec.MaskChar`
|
||
and `colsec.MaskInvert` straight from the rule.
|
||
|
||
Measured behaviour, from a standalone run of this exact function:
|
||
|
||
| input | maskStart | maskEnd | invert | output |
|
||
| --- | --- | --- | --- | --- |
|
||
| `4111111111111234` | 4 | 0 | false | `*****1111111123*` |
|
||
| `4111111111111234` | 4 | 4 | false | `*****111111*****` |
|
||
| `4111111111111234` | 0 | 4 | false | `*1111111111*****` |
|
||
| `4111111111111234` | 0 | 0 | false | `****************` |
|
||
| `4111111111111234` | 4 | 4 | true | `4111*********234` |
|
||
| `alice@example.com` | 2 | 3 | false | `***ce@example****` |
|
||
| `Müller` | 2 | 1 | false | `**ll**` |
|
||
| `日本語テスト` | 2 | 1 | false | `*本語テスト` |
|
||
|
||
Three observations.
|
||
|
||
**The bounds are off by one, in the unsafe direction for `maskEnd` and the safe
|
||
direction for `maskStart`.** `index <= maskStart` masks `maskStart+1` characters
|
||
from the front, and `index >= strLen-1-maskEnd` masks `maskEnd+1` from the back —
|
||
so `MaskStart: 4` masks five characters, not four. Rows 1–3 show it: with
|
||
`maskEnd: 4`, five trailing digits are hidden; with `maskEnd: 0`, the final digit
|
||
is still masked even though zero was requested. A rule author calibrating "show
|
||
the last four digits of a card" gets the last three.
|
||
|
||
**`maskStart == 0 && maskEnd == 0` masks the entire string** (row 4), because both
|
||
are rewritten to `strLen`. That is the right default — an unconfigured rule hides
|
||
everything rather than revealing it — but it is undocumented and surprising, and
|
||
it means there is no way to express "mask nothing".
|
||
|
||
**The offsets are byte offsets, so non-ASCII text is masked at the wrong
|
||
positions — sometimes revealing data.** `for index, char := range pString` yields
|
||
the *byte* index of each rune, and `strLen` is `len()` in bytes. For `Müller` (7
|
||
bytes, 6 runes) the requested three-from-the-front only lands on two runes,
|
||
because byte index 2 is the continuation byte of `ü` and never appears as a loop
|
||
index. For `日本語テスト` (18 bytes, 6 runes, indices 0/3/6/9/12/15) the result is
|
||
worse: `index <= 2` matches only index 0, and `index >= 16` matches nothing, so
|
||
**one of six characters is masked** and five are returned in clear. A rule written
|
||
to hide most of a value hides one sixth of it as soon as the data is not Latin-1.
|
||
Any deployment masking names, addresses or free text in a non-English locale is
|
||
under-masking today.
|
||
|
||
The `newStr += maskChar` accumulation is also O(n²) in allocations, which is
|
||
irrelevant at field sizes but trivially avoidable.
|
||
|
||
### Remediation
|
||
|
||
Operate on runes, define the parameters as counts rather than indices, and fail
|
||
safe when they are contradictory:
|
||
|
||
```go
|
||
// maskString replaces characters in s with maskChar. maskStart is the number of
|
||
// leading characters to mask and maskEnd the number of trailing characters; when
|
||
// invert is set, the middle is masked and the edges are kept. Zero for both masks
|
||
// the whole string. If the masked regions meet or overlap, the whole string is
|
||
// masked.
|
||
func maskString(s string, maskStart, maskEnd int, maskChar string, invert bool) string {
|
||
r := []rune(s)
|
||
n := len(r)
|
||
if maskChar == "" { maskChar = "*" }
|
||
if maskStart <= 0 && maskEnd <= 0 { return strings.Repeat(maskChar, n) }
|
||
if maskStart < 0 { maskStart = 0 }
|
||
if maskEnd < 0 { maskEnd = 0 }
|
||
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
for i := range r {
|
||
masked := i < maskStart || i >= n-maskEnd
|
||
if invert { masked = !masked }
|
||
if masked {
|
||
b.WriteString(maskChar)
|
||
} else {
|
||
b.WriteRune(r[i])
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
```
|
||
|
||
Note that `invert` here means exactly "keep the edges, mask the middle", which is
|
||
what the current invert branches approximate via `middleIndex` — and the
|
||
`middleIndex` arithmetic has its own edge cases (`maskStart > middleIndex` makes
|
||
the lower bound negative, masking from index 0) that this formulation removes.
|
||
|
||
Two cautions for the rollout:
|
||
|
||
* This changes output for every configured rule, because of the off-by-one. Produce
|
||
a before/after table for the rules in each deployment's `column_security` data
|
||
and adjust `MaskStart`/`MaskEnd` by one where the old behaviour was the intended
|
||
one.
|
||
* `provider_test.go:130-150` covers ASCII only. Add the `Müller` and `日本語テスト`
|
||
cases above as fixtures so the rune handling cannot regress.
|
||
|
||
---
|
||
|
||
## 26. Low — both maps are read once before their mutex is taken
|
||
|
||
```go
|
||
// provider.go:301-311
|
||
func (m *SecurityList) ApplyColumnSecurity(records reflect.Value, modelType reflect.Type, pUserID int, pSchema, pTablename string) (reflect.Value, error) {
|
||
defer logger.CatchPanic("ApplyColumnSecurity")()
|
||
|
||
if m.ColumnSecurity == nil {
|
||
return records, fmt.Errorf("security not initialized")
|
||
}
|
||
|
||
m.ColumnSecurityMutex.RLock()
|
||
defer m.ColumnSecurityMutex.RUnlock()
|
||
|
||
colsecList, ok := m.ColumnSecurity[...]
|
||
```
|
||
|
||
The same shape at `provider.go:442-452` (`GetRowSecurityTemplate`, reading
|
||
`m.RowSecurity` at `:445` before locking at `:449`) and at `:125-136`
|
||
(`ColumSecurityApplyOnRecord`, reading at `:127` before locking at `:136`).
|
||
|
||
Reading the map header outside the lock is a data race under the Go memory model
|
||
whenever another goroutine can assign the field. `LoadColumnSecurity` does exactly
|
||
that — it lazily creates the map — so a first-request race between a loader and a
|
||
reader is possible in principle. In practice the maps are created in the
|
||
constructor and only ever written *into*, not reassigned, which is why this has
|
||
not shown up: `go test -race` on the package passes. It is nevertheless
|
||
unsynchronised access to a field that another method assigns, and the race
|
||
detector will flag it the moment someone adds a `Clear`-style method that does
|
||
`m.ColumnSecurity = nil`.
|
||
|
||
The guard is also nearly useless: a nil map read returns the zero value and
|
||
`ok == false`, which the code immediately below already handles. The check exists
|
||
only to produce a different error message.
|
||
|
||
### Remediation
|
||
|
||
Move the check inside the lock, or delete it:
|
||
|
||
```go
|
||
m.ColumnSecurityMutex.RLock()
|
||
defer m.ColumnSecurityMutex.RUnlock()
|
||
|
||
colsecList, ok := m.ColumnSecurity[securityKey(pSchema, pTablename, pUserID)]
|
||
if !ok || len(colsecList) == 0 {
|
||
return records, nil, nil // see finding 22 on not returning this as an error
|
||
}
|
||
```
|
||
|
||
Reading a nil map is legal, so dropping the pre-check loses nothing. Do the same
|
||
at `:127` and `:445`.
|
||
|
||
---
|
||
|
||
## 27. Low — the security maps and their mutexes are exported
|
||
|
||
```go
|
||
// provider.go:54-61
|
||
type SecurityList struct {
|
||
provider SecurityProvider
|
||
|
||
ColumnSecurityMutex sync.RWMutex
|
||
ColumnSecurity map[string][]ColumnSecurity
|
||
RowSecurityMutex sync.RWMutex
|
||
RowSecurity map[string]RowSecurity
|
||
}
|
||
```
|
||
|
||
`provider` is correctly unexported; everything else is public. Any package holding
|
||
a `*SecurityList` can read the maps without the lock, write to them without the
|
||
lock, take the mutex and forget to release it, or copy the struct — which copies a
|
||
`sync.RWMutex` and is a `go vet` error class of its own. The invariant "the map is
|
||
only touched while its mutex is held" is unenforceable from outside the package,
|
||
and `SecurityList` is reachable through `GetSecurityList()` from every spec
|
||
adapter.
|
||
|
||
There is no reason for the exposure: every legitimate operation already has a
|
||
method (`LoadColumnSecurity`, `ApplyColumnSecurity`, `LoadRowSecurity`,
|
||
`GetRowSecurityTemplate`, `ClearSecurity`).
|
||
|
||
### Remediation
|
||
|
||
```go
|
||
type SecurityList struct {
|
||
provider SecurityProvider
|
||
|
||
colMu sync.RWMutex
|
||
columns map[string][]ColumnSecurity
|
||
rowMu sync.RWMutex
|
||
rows map[string]RowSecurity
|
||
}
|
||
```
|
||
|
||
Add `func (m *SecurityList) Snapshot() (...)` if a consumer genuinely needs to
|
||
inspect the state, returning copies. A grep for `ColumnSecurityMutex` and
|
||
`RowSecurity` outside `pkg/security` shows no external users today, so this is a
|
||
rename with no downstream churn — cheap to do now, and it stops being cheap once
|
||
an integrator depends on it.
|
||
|
||
Also embed `_ noCopy` or keep `SecurityList` pointer-only by convention; `go vet`'s
|
||
`copylocks` check catches accidental value copies, but only if the package is
|
||
vetted in CI (see `_CROSS-CUTTING.audit.md` on the lint gate).
|
||
|
||
---
|
||
|
||
## 28. Low — three nested loops share the index name `i`, and one of them indexes the wrong slice
|
||
|
||
```go
|
||
// provider.go:144-185 (abridged, indentation preserved)
|
||
for i := range colsecList {
|
||
colsec := &colsecList[i]
|
||
...
|
||
for i, path := range colsec.Path {
|
||
...
|
||
for ri := range newRecords {
|
||
...
|
||
columnData := reflection.GetModelColumnDetail(newRecords[ri])
|
||
lastColumnData := reflection.GetModelColumnDetail(lastRecords[ri])
|
||
for i, cols := range columnData {
|
||
if cols.SQLName != "" && strings.EqualFold(cols.SQLName, path) {
|
||
...
|
||
oldField = lastColumnData[i].FieldValue
|
||
break
|
||
}
|
||
if cols.Name != "" && strings.EqualFold(cols.Name, path) {
|
||
...
|
||
oldField = lastColumnData[i].FieldValue
|
||
break
|
||
}
|
||
```
|
||
|
||
`i` is declared three times in nested scopes (`:144`, `:153`, `:170`), each
|
||
shadowing the last. The innermost `i` is what `:175` and `:182` use, which is
|
||
correct for `columnData` — but `lastColumnData` is a *separate* call to
|
||
`GetModelColumnDetail` on a *different* value (`lastRecords[ri]` versus
|
||
`newRecords[ri]`), and the code assumes the two slices are index-aligned.
|
||
|
||
They are aligned as long as both records are the same struct type, which
|
||
`ColumSecurityApplyOnRecord` verifies at `:130-133`. So this is not a live
|
||
mis-indexing today. It is fragile in two specific ways:
|
||
|
||
* `GetModelColumnDetail` walks embedded structs and relations; if it ever returns
|
||
a different number of entries for two values of the same type — for example
|
||
because a relation pointer is nil on one and populated on the other — the
|
||
alignment breaks and `oldField` silently becomes a different column's value. In
|
||
`ColumSecurityApplyOnRecord` that value is used to decide whether the field
|
||
changed, so a mis-alignment would mean a modified protected column is treated as
|
||
unmodified: an authorisation decision made on the wrong field.
|
||
* `lastColumnData[i]` is an unchecked index. If `lastColumnData` is shorter than
|
||
`columnData`, this panics — and the panic is recovered by
|
||
`logger.CatchPanic("ApplyColumnSecurity")` in the sibling function
|
||
(`provider.go:302`), which returns the *unmasked* records with a nil error
|
||
(finding 3).
|
||
|
||
The shadowing is what makes this hard to see: a reader checking whether `i` is the
|
||
right index has to count three scopes.
|
||
|
||
### Remediation
|
||
|
||
Rename the indices to say what they are, and look the old field up by name rather
|
||
than by position:
|
||
|
||
```go
|
||
for ci := range colsecList {
|
||
colsec := &colsecList[ci]
|
||
for pi, path := range colsec.Path {
|
||
for ri := range newRecords {
|
||
columnData := reflection.GetModelColumnDetail(newRecords[ri])
|
||
lastByName := indexByName(reflection.GetModelColumnDetail(lastRecords[ri]))
|
||
for _, col := range columnData {
|
||
if !matchesPath(col, path) { continue }
|
||
oldField, ok := lastByName[strings.ToLower(col.SQLName)]
|
||
if !ok {
|
||
return cols, fmt.Errorf("column %q present in new record but not in previous", col.SQLName)
|
||
}
|
||
...
|
||
```
|
||
|
||
Matching by name removes the positional assumption entirely and turns a silent
|
||
mis-alignment into an explicit error. Note the `pi`/`pathLen` interaction at
|
||
`:155-159` also depends on the middle `i`; renaming makes that logic legible too.
|
||
|
||
`go vet -shadow` (or `golangci-lint`'s `govet` with `shadow` enabled) catches this
|
||
class; it is not currently enabled.
|
||
|
||
---
|
||
|
||
## 29. Low — `ClearSecurity` deletes everything it is asked to keep, and has no callers
|
||
|
||
```go
|
||
// provider.go:397-417
|
||
func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) error {
|
||
var filtered []ColumnSecurity
|
||
m.ColumnSecurityMutex.Lock()
|
||
defer m.ColumnSecurityMutex.Unlock()
|
||
|
||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
||
list, ok := m.ColumnSecurity[secKey]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
|
||
for i := range list {
|
||
cs := &list[i]
|
||
if cs.Schema != pSchema && cs.Tablename != pTablename && cs.UserID != pUserID {
|
||
filtered = append(filtered, *cs)
|
||
}
|
||
}
|
||
|
||
m.ColumnSecurity[secKey] = filtered
|
||
return nil
|
||
}
|
||
```
|
||
|
||
The entries under `secKey` are, by construction, exactly the entries whose schema,
|
||
table and user match the key. So `cs.Schema != pSchema && cs.Tablename !=
|
||
pTablename && cs.UserID != pUserID` is false for every one of them, `filtered`
|
||
stays nil, and the function assigns a nil slice — wiping the whole bucket rather
|
||
than clearing a subset of it. Even reading the condition charitably as intended
|
||
(`||` rather than `&&`), it would still be a no-op filter for the same reason: the
|
||
key already pins all three fields.
|
||
|
||
The effect of the wipe is not a security hole in the fail-open sense, because
|
||
`ApplyColumnSecurity` treats a nil list as "no rules" and returns an error rather
|
||
than data — but combined with `hooks.go:182-187`, which logs and continues when
|
||
`ApplyColumnSecurity` fails, a wiped bucket means **unmasked data** until the next
|
||
`LoadColumnSecurity` with `pOverwrite`.
|
||
|
||
It is also never called: grepping the repository for `ClearSecurity` finds the
|
||
definition and nothing else — no production caller, no test. Another X10 instance.
|
||
And it does not touch `RowSecurity` at all, so the one cache that most needs
|
||
invalidation (finding 14) has no invalidation path.
|
||
|
||
### Remediation
|
||
|
||
Decide which it is.
|
||
|
||
**If per-key invalidation is wanted,** it is a `delete`, not a filter, and it needs
|
||
to cover both maps:
|
||
|
||
```go
|
||
func (m *SecurityList) ClearSecurity(userID any, schema, table string) {
|
||
key := securityKey(schema, table, userID)
|
||
|
||
m.colMu.Lock()
|
||
delete(m.columns, key)
|
||
m.colMu.Unlock()
|
||
|
||
m.rowMu.Lock()
|
||
delete(m.rows, key)
|
||
m.rowMu.Unlock()
|
||
}
|
||
```
|
||
|
||
Then call it — from logout (`providers.go:316-320`, `providers_direct.go:182-206`)
|
||
and from whatever administrative path changes a rule.
|
||
|
||
**If it is not wanted,** delete the function. Dead security code is worse than
|
||
absent security code, because a reader assumes it works.
|
||
|
||
Either way, once finding 6 introduces real caching the invalidation story has to be
|
||
designed deliberately, and `pkg/cache`'s tag support (`DeleteByTag`,
|
||
`cache/provider_memory.go:194-228`) is the better substrate: tag entries with
|
||
`table:orders` and a rule change invalidates every user's copy in one call.
|
||
|
||
---
|
||
|
||
## 30. Low — string building by concatenation in per-field loops
|
||
|
||
`maskString` (`provider.go:102-120`) and `splitTag` (`hooks.go:424-441`) both build
|
||
their results with `+=` inside a loop:
|
||
|
||
```go
|
||
// hooks.go:427-436
|
||
for _, ch := range tag {
|
||
if ch == sep {
|
||
if current != "" {
|
||
parts = append(parts, current)
|
||
current = ""
|
||
}
|
||
} else {
|
||
current += string(ch)
|
||
}
|
||
}
|
||
```
|
||
|
||
Each `+=` allocates a new string and copies the accumulated prefix, so both are
|
||
O(n²) in bytes copied. `string(ch)` additionally allocates per rune.
|
||
|
||
At the sizes involved — struct tags, masked field values — this is a few hundred
|
||
bytes of garbage per call and will never appear in a profile. It is listed because
|
||
`maskString` runs once per masked field per record, so a 10 000-row response with
|
||
three masked columns calls it 30 000 times, and the fix is free.
|
||
|
||
### Remediation
|
||
|
||
`strings.Builder` in `maskString` (shown in finding 25), and `strings.Split` — or
|
||
`strings.FieldsFunc` if empty parts must be dropped, which is what the `current !=
|
||
""` check is doing — in place of `splitTag`:
|
||
|
||
```go
|
||
func splitTag(tag string, sep rune) []string {
|
||
return strings.FieldsFunc(tag, func(r rune) bool { return r == sep })
|
||
}
|
||
```
|
||
|
||
`FieldsFunc` already omits empty fields, so this is behaviour-preserving. Better
|
||
still, delete `splitTag` along with `contains` and `extractSQLName` (finding 24) and
|
||
use `common.ExtractTagValue` (`common/handler_utils.go:53-55`).
|
||
|
||
---
|
||
|
||
## 31. Low — registration reads the new user's ID with `LastInsertId`, after a non-transactional uniqueness check
|
||
|
||
```go
|
||
// providers_direct.go:105-130 (abridged)
|
||
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||
var count int
|
||
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE username = ?`, a.tableNames.Users))
|
||
if err := db.QueryRowContext(ctx, checkQuery, req.Username).Scan(&count); err != nil { return err }
|
||
if count > 0 { return errUsernameExists }
|
||
|
||
checkQuery2 := ... `SELECT COUNT(*) FROM %s WHERE email = ?` ...
|
||
if count > 0 { return errEmailExists }
|
||
|
||
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||
`INSERT INTO %s (username, email, password, ...) VALUES (?, ?, ?, ...)`, a.tableNames.Users))
|
||
res, err := db.ExecContext(ctx, insertQuery, req.Username, req.Email, req.Password, req.UserLevel, rolesStr, true, now, now, 0, "")
|
||
if err != nil { return err }
|
||
userID, err = res.LastInsertId()
|
||
return err
|
||
})
|
||
```
|
||
|
||
Two portability defects in six lines.
|
||
|
||
**`LastInsertId` is not supported by `lib/pq` or `pgx`.** Those drivers return
|
||
`0, errors.New("LastInsertId is not supported by this driver")`, so on PostgreSQL —
|
||
the dialect the rest of this package is written against, with `pg_proc` probes and
|
||
`$n` placeholders — registration returns an error *after having created the user*.
|
||
The user exists, the caller sees a failure, and a retry hits `errUsernameExists`.
|
||
The correct form is `INSERT … RETURNING id` with `QueryRowContext`, which is also
|
||
what the stored-procedure path effectively does.
|
||
|
||
**Check-then-insert is a race, not a constraint.** Two concurrent registrations
|
||
with the same username both read `count == 0` and both insert. There is no
|
||
transaction around the sequence (each `db.ExecContext`/`QueryRowContext` is
|
||
autocommitted independently) and no `SELECT … FOR UPDATE`. Whether a duplicate
|
||
results depends entirely on whether the `users` table has unique indexes on
|
||
`username` and `email` — `database_schema.sql:9` does declare them, so the database
|
||
saves this, but then the error surfaced to the caller is the driver's constraint
|
||
violation rather than `errUsernameExists`, and the carefully written enumeration
|
||
handling at `:133-140` is bypassed.
|
||
|
||
Both are behind `registerDirect`, whose more serious problems — storing
|
||
`req.Password` in plaintext at `:125`, and accepting caller-supplied `UserLevel`
|
||
and `Roles` — are covered in findings 1 and 15.
|
||
|
||
### Remediation
|
||
|
||
```go
|
||
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||
q := rewritePlaceholders(db, fmt.Sprintf(
|
||
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, a.tableNames.Users))
|
||
return db.QueryRowContext(ctx, q, req.Username, req.Email, hashed, level, rolesStr, true, now, now, 0, "").Scan(&userID)
|
||
})
|
||
if err != nil {
|
||
switch {
|
||
case isUniqueViolation(err, "users_username_key"): return nil, errUsernameExists
|
||
case isUniqueViolation(err, "users_email_key"): return nil, errEmailExists
|
||
}
|
||
return nil, fmt.Errorf("registration failed: %w", err)
|
||
}
|
||
```
|
||
|
||
Let the unique constraint be the check — it is the only one that is actually
|
||
atomic — and map the violation back to the existing sentinel errors so the
|
||
anti-enumeration behaviour at `:133-140` still applies. For MySQL/SQLite support,
|
||
`RETURNING` needs a dialect switch; `rewritePlaceholders` (`query_mode.go:141-157`)
|
||
is the existing seam for that.
|
||
|
||
---
|
||
|
||
## 32. Low — `authenticateCallback` may return `(nil, nil)`, and the caller does not check
|
||
|
||
```go
|
||
// providers.go:382-387
|
||
if len(tokens) == 0 {
|
||
if a.authenticateCallback != nil {
|
||
return a.authenticateCallback(r)
|
||
}
|
||
return nil, fmt.Errorf("session token required")
|
||
}
|
||
```
|
||
|
||
```go
|
||
// providers.go:452-459
|
||
// All tokens failed — try callback before returning error
|
||
if a.authenticateCallback != nil {
|
||
return a.authenticateCallback(r)
|
||
}
|
||
if lastErr != nil {
|
||
return nil, lastErr
|
||
}
|
||
return nil, fmt.Errorf("authentication failed for all provided tokens")
|
||
```
|
||
|
||
`authenticateCallback` is an integrator-supplied `func(*http.Request)
|
||
(*UserContext, error)` (`providers.go:126-129`). Its result is returned
|
||
unexamined, so a callback that returns `(nil, nil)` — a plausible reading of "I
|
||
have no opinion about this request" — propagates a nil `*UserContext` with a nil
|
||
error to `authenticateRequest`:
|
||
|
||
```go
|
||
// middleware.go:82-91
|
||
userCtx, err := provider.Authenticate(r)
|
||
if err != nil {
|
||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
return setUserContext(r, userCtx), true
|
||
```
|
||
|
||
which passes it to `setUserContext`:
|
||
|
||
```go
|
||
// middleware.go:59-78 (abridged)
|
||
func setUserContext(r *http.Request, userCtx *UserContext) *http.Request {
|
||
ctx := r.Context()
|
||
ctx = context.WithValue(ctx, UserContextKey, userCtx)
|
||
ctx = context.WithValue(ctx, UserIDKey, userCtx.UserID)
|
||
...
|
||
```
|
||
|
||
`userCtx.UserID` on a nil pointer panics. The panic happens inside the middleware
|
||
chain, where the outermost recovery middleware turns it into a 500 — so the
|
||
outcome is a crash-per-request, not a bypass, provided the recovery middleware is
|
||
installed. The doc comment at `:126-129` does not state the contract, so a
|
||
callback author has no way to know `(nil, nil)` is forbidden.
|
||
|
||
### Remediation
|
||
|
||
Validate at the boundary — the callback is external code, so treat its result as
|
||
untrusted:
|
||
|
||
```go
|
||
if a.authenticateCallback != nil {
|
||
userCtx, err := a.authenticateCallback(r)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if userCtx == nil {
|
||
return nil, fmt.Errorf("authentication callback returned no user context")
|
||
}
|
||
return userCtx, nil
|
||
}
|
||
```
|
||
|
||
and defensively in the middleware, as shown in finding 18:
|
||
|
||
```go
|
||
if userCtx == nil {
|
||
logger.Error("authenticator %T returned nil user context with nil error", provider)
|
||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||
return nil, false
|
||
}
|
||
```
|
||
|
||
Document the contract on the callback field: *"must return a non-nil UserContext or
|
||
a non-nil error; returning (nil, nil) is a programming error."* Same treatment for
|
||
`HeaderAuthenticator`'s equivalent hook (finding 8).
|
||
|
||
---
|
||
|
||
## 33. Low — `RequestPasswordReset` returns the raw token to its caller
|
||
|
||
```go
|
||
// providers_direct.go:336-359 (abridged)
|
||
rawBytes := make([]byte, 32)
|
||
if _, err := rand.Read(rawBytes); err != nil {
|
||
return nil, fmt.Errorf("failed to generate reset token: %w", err)
|
||
}
|
||
rawToken := hex.EncodeToString(rawBytes)
|
||
hash := sha256.Sum256([]byte(rawToken))
|
||
tokenHash := hex.EncodeToString(hash[:])
|
||
...
|
||
return &PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil
|
||
```
|
||
|
||
The cryptography here is right, and worth saying so: 256 bits from `crypto/rand`,
|
||
only the SHA-256 hash is stored (`:352`), a one-hour expiry, prior unused tokens
|
||
are deleted before insert, `used`/`used_at` are recorded on completion
|
||
(`:399-400`), and all sessions are invalidated when the password changes
|
||
(`:395-397`). The enumeration-resistant early return at `:330-332` — generic
|
||
success when the user does not exist — is also correct.
|
||
|
||
The issue is the return value. `PasswordResetResponse.Token` carries the raw token
|
||
back to the caller, and the caller is an HTTP handler. Whether that token reaches
|
||
the requester's inbox (correct) or the HTTP response body (an account-takeover
|
||
primitive: anyone who can POST `/password-reset` for `victim@example.com` receives
|
||
the reset token) is entirely up to the integrator, and nothing in the signature,
|
||
the doc comment or `SECURITY_FEATURES.md` says which is intended.
|
||
|
||
There is a second, quieter exposure: the raw token is now in the caller's memory
|
||
and in any structure the caller logs. `PasswordResetResponse` has no custom
|
||
`String()`, so `logger.Debug("reset response: %+v", resp)` prints it — and if
|
||
anyone writes that at `Warn`, it goes to Sentry (X8).
|
||
|
||
### Remediation
|
||
|
||
Make the safe path the only path: have the package deliver the token and return
|
||
nothing sensitive.
|
||
|
||
```go
|
||
type PasswordResetResponse struct {
|
||
// Requested is always true for a well-formed request, whether or not the
|
||
// address matched an account. The token is never returned; it is delivered
|
||
// through the configured Notifier.
|
||
Requested bool
|
||
ExpiresIn int
|
||
}
|
||
|
||
type ResetNotifier interface {
|
||
SendPasswordReset(ctx context.Context, email, rawToken string, expiresIn time.Duration) error
|
||
}
|
||
```
|
||
|
||
If returning the token must stay — for tests, or for integrators who deliver it
|
||
themselves — then:
|
||
|
||
1. Rename the field to `RawTokenDoNotLogOrReturn` or gate it behind an explicit
|
||
`AllowTokenInResponse bool` on the authenticator, defaulting to false.
|
||
2. Add `func (r PasswordResetResponse) String() string { return "PasswordResetResponse{redacted}" }`
|
||
so `%v`/`%+v` cannot print it.
|
||
3. State the requirement in the doc comment and in `SECURITY_FEATURES.md`: *the
|
||
token must never appear in an HTTP response.*
|
||
4. Rate-limit the endpoint per account and per IP — the reset path is otherwise an
|
||
unauthenticated way to generate a row in `user_password_resets` per request
|
||
(`middleware.audit.md` finding 1 again).
|
||
|
||
---
|
||
|
||
## Cross-cutting references
|
||
|
||
**X8 — `Warn`/`Error` are forwarded to the error tracker; `Info`/`Debug` are not.**
|
||
Confirmed at `logger/logger.go:118-122` and `:135-139` against `:100-106` and
|
||
`:142-148`. This package is the largest source of X8 exposure in the repository,
|
||
in both directions:
|
||
|
||
The complete `Warn`/`Error` inventory for this package is eight lines, and six of
|
||
them are problems:
|
||
|
||
| site | level | reached when | problem |
|
||
| --- | --- | --- | --- |
|
||
| `providers.go:391` | Warn | one comma in an `Authorization` header | logs the **raw header** — finding 7 |
|
||
| `hooks.go:36` | Warn | any request with no user ID | fires on every guest request |
|
||
| `hooks.go:48` | Warn | column rules cannot be loaded | every request in Direct mode — finding 22 |
|
||
| `hooks.go:61` | Warn | row rules cannot be loaded | every request in Direct mode — finding 22 |
|
||
| `hooks.go:93` | Warn | a user is blocked | logs the rendered `userRef` — `*UserContext` with session token and claims |
|
||
| `hooks.go:184` | Warn | a table has no column rules | every secured read — finding 22 |
|
||
| `provider.go:132` | Error | prev/new record type mismatch | appropriate |
|
||
| `provider.go:204` | Warn | an unsettable field | once per unsettable field per record — finding 4 |
|
||
|
||
Five of the six are attacker-triggerable with no rate limiter in front of them
|
||
(`middleware.audit.md` finding 1), and three of them fire during entirely normal
|
||
operation. Any one will exhaust a Sentry quota from a single client. `hooks.go:93`
|
||
and `providers.go:391` additionally put a credential into the error tracker.
|
||
|
||
The fix in each case is to choose the level by *who causes the event*: client
|
||
behaviour is `Debug`, operator misconfiguration is `Warn` (once, at startup), and
|
||
only genuine server-side faults are `Error`. `hooks.go:93` is the one case where
|
||
`Warn` is right — a blocked access attempt is worth an alert — and it needs only the
|
||
redacting `String()` from finding 7 to stop leaking.
|
||
|
||
**X10 — configured, written, and never installed.** Two new instances from this
|
||
package:
|
||
|
||
* `ValidateSQLNames` (`sql_names.go:242-258`) and `ValidateTableNames`
|
||
(`table_names.go:82-96`) have no production callers (finding 17).
|
||
* `ClearSecurity` (`provider.go:397-417`) has no callers at all (finding 29).
|
||
|
||
To which finding 2 adds a third and worst variant: row-level security is *wired*
|
||
but inert, because the type assertion at `hooks.go:134-136` can never succeed.
|
||
|
||
**`pkg/logger`.** `CatchPanic` on a function with unnamed results returns zero
|
||
values and a nil error, which is how finding 3 turns a panic into "masking
|
||
succeeded". `logger.HandlePanic(name, r)` with a named `err` result is the correct
|
||
helper. See `logger.audit.md` finding 2 for the general form; `pkg/security` has
|
||
the two most consequential instances (`provider.go:302`, `:443`).
|
||
|
||
**`pkg/modelregistry`.** `modelregistry.audit.md` finding 1 (lookup by name misses
|
||
schema-qualified registrations) is the upstream half of finding 15: the registry
|
||
fails to find the model, and `hooks.go:287`/`:311` turn that into `return nil //
|
||
model not registered, allow by default`.
|
||
|
||
**`pkg/cache`.** The natural substrate for finding 6 (no caching at all) and
|
||
finding 14 (unbounded maps): `MaxSize` with LRU eviction
|
||
(`cache/provider_memory.go:105-109`, `:307-326`) and tag-based invalidation
|
||
(`SetWithTags` `:121-170`, `DeleteByTag` `:194-228`). `restheadspec` already uses
|
||
the tag pattern for query totals (`restheadspec/cache_helpers.go:99-113`), so
|
||
there is a working precedent in-tree.
|
||
|
||
**`pkg/middleware`.** `middleware.audit.md` finding 1 — no rate limiter is
|
||
installed by default — is what makes findings 11, 18, 22 and 33 practical rather
|
||
than theoretical. `pkg/security`'s own `middleware.go` is separate from
|
||
`pkg/middleware` and composes with it; the recovery middleware from that package
|
||
is what keeps finding 32 to a 500 rather than a crash.
|
||
|
||
**`pkg/config`.** There is no `security` section in `setDefaults`
|
||
(`config/manager.go:170-293`), so nothing in this package is configurable through
|
||
the standard config path — the OAuth signing key (finding 23), the query mode
|
||
(finding 19) and the table/SQL name overrides (finding 17) are all constructor
|
||
arguments only. Adding them is the prerequisite for making the safe options the
|
||
default in deployment.
|
||
|
||
**`pkg/restheadspec`.** `security_hooks.go:14-58` is the wiring that registers
|
||
every hook discussed here, and `handler.go:396-400`/`:424` is what guarantees
|
||
`ApplyColumnSecurity` always receives a slice (which is why the read path does not
|
||
have the single-record masking gap it first appears to have). Separately, and
|
||
unrelated to security: `handler.go:1006` passes `modelPtr` to the response writer
|
||
rather than `hookCtx.Result`, so an `AfterRead` hook that *replaces* the result has
|
||
no effect — worth noting here because a hook that redacts by substitution would
|
||
silently do nothing.
|
||
|
||
---
|
||
|
||
## Recommended order of work
|
||
|
||
The list is ordered by risk reduced per unit of effort, not by finding number.
|
||
|
||
1. **Verify passwords (finding 1).** Everything else is secondary to the fact that
|
||
`loginDirect` never reads `req.Password` and the stored procedure compares it to
|
||
nothing. Add bcrypt (or argon2id) hashing on registration and comparison on
|
||
login, in both query modes, with a migration path for existing plaintext rows.
|
||
Until this is done the package has no authentication.
|
||
2. **Make row-level security actually run, and fix the injection it enables at the
|
||
same time (findings 2 and the `GetTemplate` interpolation).** Replace the
|
||
impossible assertion at `hooks.go:134-136` with the real `common.SelectQuery`
|
||
interface, and make `GetTemplate` bind `{UserID}` as a parameter instead of
|
||
`fmt.Sprintf("%v", …)` (`provider.go:48`). These must ship together: turning the
|
||
filter on while it still interpolates its user ID into SQL would introduce a
|
||
live injection where today there is only dead code. Add an integration test that
|
||
asserts a second tenant's rows are absent from the result.
|
||
3. **Replace `CatchPanic` with `HandlePanic` on the two masking functions
|
||
(finding 3).** Two lines, and it converts "panic ⇒ unmasked data, nil error"
|
||
into "panic ⇒ error".
|
||
4. **Stop holding the global write locks across provider calls (finding 6).** Call
|
||
the provider outside the lock; take the lock only to publish the result. This is
|
||
the whole of the package's throughput problem and it is a mechanical change.
|
||
5. **Fail closed, now that failures are distinguishable (findings 5, 15 and 22).**
|
||
Change `GetRowSecurityTemplate`/`ApplyColumnSecurity` to return `(value, ok,
|
||
err)`, then deny on `err` and skip silently on `!ok`; make an unregistered model
|
||
a denial rather than a permission. Step 22's return-type change is what makes
|
||
this possible, so do it here rather than earlier.
|
||
6. **Add a redacting `String()` to `UserContext` and drop the credential log lines
|
||
(finding 7).** One method plus four log-line edits removes session tokens and
|
||
`Authorization` headers from logs, from Sentry, and from the cache keys in
|
||
finding 14.
|
||
7. **Close the four token-handling gaps (findings 9, 10, 11, 34).** Reject
|
||
multi-token `Authorization` headers instead of trying each; strip the scheme
|
||
prefix before building the logout cache key; gate or bound `/oauth/register`;
|
||
and copy `ClientSecretHash`/`TokenEndpointAuthMethod` in
|
||
`lookupOrFetchClient` so a restart stops turning confidential clients into
|
||
public ones. Finding 34 is a three-line fix for a High, so it can go first.
|
||
8. **Fix the masking primitives (findings 4 and 25).** Type-switch instead of
|
||
kind-string matching so `float*`, `bool`, `time.Time` and `[]byte` are actually
|
||
masked, guard every setter with `CanSet()`, and rewrite `maskString` on runes
|
||
with corrected bounds. Ship with a before/after table, because output changes.
|
||
9. **Build the audit log, or stop calling it one (finding 16).**
|
||
10. **The remainder** — findings 12, 13, 17, 19, 20, 21, 23, 24, 26–33 — in any
|
||
order. Findings 12 (delete or implement `JWTAuthenticator`) and 17 (call the
|
||
identifier validators) are the two that remove the most latent risk for the
|
||
least work.
|
||
|
||
Two things to preserve while doing all of this. The **OAuth 2.1 authorization-code
|
||
grant** (`oauth_server.go:794-869`) is correct: mandatory PKCE with S256 only,
|
||
exact `redirect_uri` matching, single-use codes, confidential-client
|
||
authentication, `subtle.ConstantTimeCompare` for secrets. The **password-reset
|
||
flow** (`providers_direct.go:310-407`) is correct: 256 bits from `crypto/rand`,
|
||
hash-at-rest, one-hour expiry, single use, session invalidation on completion, and
|
||
enumeration-resistant responses. Both are the standard the rest of the package
|
||
should be brought up to, and neither should be refactored casually in the course of
|
||
fixing the items above.
|