diff --git a/Dockerfile b/Dockerfile index 8f4649e..f8a5f36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # # Runtime stays on golang so Scythe.embedded can `go build` at request time, but we only # ship the binary + Scythe sources + Operator AI skill files (not the full vendor/ source tree). -FROM golang:1.25.12-bookworm AS builder +FROM golang:1.25.13-bookworm AS builder ARG TARGETOS=linux ARG TARGETARCH @@ -45,7 +45,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ go build -mod=mod -trimpath -ldflags="-s -w" -o /out/ReaperC2 .; \ fi -FROM golang:1.25.12-bookworm +FROM golang:1.25.13-bookworm # Refresh OS packages to pick up Debian security updates in the golang base image. RUN apt-get update \ diff --git a/Dockerfile.pack b/Dockerfile.pack index 8da3359..526f11f 100644 --- a/Dockerfile.pack +++ b/Dockerfile.pack @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 # Package a host-built ReaperC2 binary (see `make build-binaries`). No `go build` in Docker. -FROM golang:1.25.12-bookworm AS pack +FROM golang:1.25.13-bookworm AS pack ARG TARGETARCH ARG SCYTHE_GIT_REF=main @@ -24,7 +24,7 @@ RUN set -eux; \ fi; \ test -f third_party/Scythe/go.mod -FROM golang:1.25.12-bookworm +FROM golang:1.25.13-bookworm RUN apt-get update \ && apt-get upgrade -y --no-install-recommends \ diff --git a/deployments/k8s/ollama.yaml b/deployments/k8s/ollama.yaml index f9549a6..b2e9bbb 100644 --- a/deployments/k8s/ollama.yaml +++ b/deployments/k8s/ollama.yaml @@ -34,6 +34,7 @@ metadata: spec: accessModes: - ReadWriteOnce + storageClassName: gp2 resources: requests: storage: 20Gi diff --git a/docs/operator-guide-users.md b/docs/operator-guide-users.md index 4076887..504aa09 100644 --- a/docs/operator-guide-users.md +++ b/docs/operator-guide-users.md @@ -3,6 +3,6 @@ **Path:** `/users` **Requires:** Admin role -Create portal accounts and assign **Admin** or **Operator**. Enable/disable accounts (disabled users cannot sign in). Admins cannot disable themselves from the list actions. +Create portal accounts and assign **Admin** or **Operator**. Change an existing user’s role with the **Role** dropdown in the accounts table (admins only; you cannot change your own role). Enable/disable accounts (disabled users cannot sign in). Admins cannot disable themselves from the list actions. -API: `POST /api/users` and related user management endpoints. Operators do not see this page. +API: `POST /api/users`, `PATCH /api/users/{username}` with `{ "role": "admin" | "operator" }` and/or `{ "disabled": true | false }`. Operators do not see this page. diff --git a/go.mod b/go.mod index 4cf3c0a..46ec52d 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module ReaperC2 go 1.25.0 -toolchain go1.25.12 +toolchain go1.25.13 require ( github.com/aws/aws-sdk-go-v2 v1.41.7 @@ -14,7 +14,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/pquerna/otp v1.5.0 github.com/yuin/goldmark v1.7.17 - go.mongodb.org/mongo-driver v1.17.3 + go.mongodb.org/mongo-driver v1.17.7 golang.org/x/crypto v0.54.0 ) diff --git a/go.sum b/go.sum index 0b00c9d..39007a0 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= +go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= diff --git a/pkg/adminpanel/ai_drawer.go b/pkg/adminpanel/ai_drawer.go index a4a2bac..ec12a71 100644 --- a/pkg/adminpanel/ai_drawer.go +++ b/pkg/adminpanel/ai_drawer.go @@ -365,8 +365,12 @@ func operatorAIDrawerScript(hasEngagement bool) string { if (r.ok && j.reply) { var label = j.provider ? (' [' + j.provider + (j.model ? ' · ' + j.model : '') + ']') : ''; history.push({ role: 'assistant', content: j.reply + label }); + } else if (j && j.error) { + history.push({ role: 'assistant', content: j.error }); + } else if (r.ok) { + history.push({ role: 'assistant', content: 'Model returned an empty reply. Try again or choose another model.' }); } else { - history.push({ role: 'assistant', content: (j && j.error) ? j.error : (r.status + ' ' + r.statusText) }); + history.push({ role: 'assistant', content: r.status + ' ' + r.statusText }); } } catch (e) { history = history.filter(function(m) { return !m.thinking; }); diff --git a/pkg/adminpanel/ghostwriter_export.go b/pkg/adminpanel/ghostwriter_export.go index 857de34..68aa8ea 100644 --- a/pkg/adminpanel/ghostwriter_export.go +++ b/pkg/adminpanel/ghostwriter_export.go @@ -260,6 +260,8 @@ func ghostwriterUserContext(action string) string { return "Admin | user disabled" case dbconnections.AuditActionUserEnabled: return "Admin | user enabled" + case dbconnections.AuditActionUserRoleUpdated: + return "Admin | user role updated" case dbconnections.AuditActionEngagementOperatorsUpdated: return "Admin | engagement operators updated" case dbconnections.AuditActionAIChat: diff --git a/pkg/adminpanel/handlers_users.go b/pkg/adminpanel/handlers_users.go index a9bc43d..7d9cc90 100644 --- a/pkg/adminpanel/handlers_users.go +++ b/pkg/adminpanel/handlers_users.go @@ -67,7 +67,31 @@ func (s *Server) handleUsersPage(w http.ResponseWriter, r *http.Request) { rows.WriteString("") rows.WriteString(template.HTMLEscapeString(op.Username)) rows.WriteString("") - rows.WriteString(template.HTMLEscapeString(rn)) + if op.Username == u { + rows.WriteString(template.HTMLEscapeString(rn)) + } else { + rows.WriteString(``) + } rows.WriteString("") rows.WriteString(template.HTMLEscapeString(st)) rows.WriteString("") @@ -95,7 +119,7 @@ func (s *Server) handleUsersPage(w http.ResponseWriter, r *http.Request) { selfQuoted, _ := json.Marshal(u) body := `

Users

-

Create portal accounts. Disabled users cannot sign in; their sessions end immediately. You cannot disable yourself. Admin may manage users and All logs; Operator may use beacons, commands, reports, topology, chat, and Engagement logs for the selected engagement.

+

Create portal accounts and change roles. Disabled users cannot sign in; their sessions end immediately. You cannot disable yourself or change your own role. Admin may manage users and All logs; Operator may use beacons, commands, reports, topology, chat, and Engagement logs for the selected engagement.

Create user

@@ -131,32 +155,48 @@ document.getElementById('createu').onclick = async function() { out.textContent = r.ok ? JSON.stringify(j, null, 2) : (j.error || r.statusText); }; document.getElementById('refusers').onclick = function() { location.reload(); }; -async function patchUser(username, disabled) { +async function patchUser(username, body) { var r = await fetch('/api/users/' + encodeURIComponent(username), { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: disabled }) + body: JSON.stringify(body) }); var j = await r.json().catch(function() { return {}; }); - if (!r.ok) { alert(j.error || r.statusText); return; } - location.reload(); + if (!r.ok) { alert(j.error || r.statusText); return false; } + return true; } document.querySelectorAll('[data-disable]').forEach(function(btn) { - btn.onclick = function() { + btn.onclick = async function() { var name = btn.getAttribute('data-disable'); if (!name || name === window.__USERS_SELF__) return; if (!confirm('Disable ' + name + '? They will be signed out and cannot log in until re-enabled.')) return; - patchUser(name, true); + if (await patchUser(name, { disabled: true })) location.reload(); }; }); document.querySelectorAll('[data-enable]').forEach(function(btn) { - btn.onclick = function() { + btn.onclick = async function() { var name = btn.getAttribute('data-enable'); if (!name) return; - patchUser(name, false); + if (await patchUser(name, { disabled: false })) location.reload(); }; }); +document.querySelectorAll('.user-role-select').forEach(function(sel) { + var initial = sel.value; + sel.addEventListener('change', async function() { + var name = sel.getAttribute('data-username'); + var role = sel.value; + if (!name || name === window.__USERS_SELF__) { sel.value = initial; return; } + var label = role === 'admin' ? 'Admin' : 'Operator'; + if (!confirm('Change ' + name + ' to ' + label + '?')) { sel.value = initial; return; } + if (await patchUser(name, { role: role })) { + initial = role; + location.reload(); + } else { + sel.value = initial; + } + }); +}); ` s.writeAppPage(w, u, role, "users", "Users", body, nil) } @@ -240,7 +280,7 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request) return } target := strings.TrimSpace(mux.Vars(r)["username"]) - if target == "" { + if target == "" || !isValidUsername(target) { jsonError(w, http.StatusBadRequest, "username required") return } @@ -249,13 +289,14 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request) return } var req struct { - Disabled *bool `json:"disabled"` + Disabled *bool `json:"disabled"` + Role *string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, "invalid json") return } - if req.Disabled == nil { + if req.Disabled == nil && req.Role == nil { jsonError(w, http.StatusBadRequest, "no changes") return } @@ -270,7 +311,54 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request) jsonError(w, http.StatusInternalServerError, "lookup failed") return } - if *req.Disabled && isAdmin(effectivePortalRole(targetOp)) { + resp := map[string]interface{}{"ok": true, "username": target} + currentRole := effectivePortalRole(targetOp) + + if req.Role != nil { + newRole := strings.ToLower(strings.TrimSpace(*req.Role)) + if newRole != dbconnections.RoleAdmin && newRole != dbconnections.RoleOperator { + jsonError(w, http.StatusBadRequest, "role must be admin or operator") + return + } + if newRole != currentRole { + if currentRole == dbconnections.RoleAdmin && newRole == dbconnections.RoleOperator && !dbconnections.OperatorIsDisabled(targetOp) { + n, err := dbconnections.CountActiveAdminsExcluding(ctx, target) + if err != nil { + log.Printf("admin: count active admins: %v", err) + jsonError(w, http.StatusInternalServerError, "failed") + return + } + if n == 0 { + jsonError(w, http.StatusBadRequest, "cannot demote the last active administrator") + return + } + } + if err := dbconnections.SetOperatorRole(ctx, target, newRole); err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + jsonError(w, http.StatusNotFound, "user not found") + return + } + jsonError(w, http.StatusInternalServerError, "failed to update role") + return + } + if aerr := dbconnections.InsertAuditLog(ctx, adminUser, dbconnections.AuditActionUserRoleUpdated, bson.M{ + "target_username": target, + "old_role": currentRole, + "new_role": newRole, + }, ""); aerr != nil { + log.Printf("admin: audit user role: %v", aerr) + } + currentRole = newRole + resp["role"] = newRole + } + } + + if req.Disabled == nil { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + return + } + if *req.Disabled && currentRole == dbconnections.RoleAdmin && !dbconnections.OperatorIsDisabled(targetOp) { n, err := dbconnections.CountActiveAdminsExcluding(ctx, target) if err != nil { log.Printf("admin: count active admins: %v", err) @@ -299,8 +387,9 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request) if aerr := dbconnections.InsertAuditLog(ctx, adminUser, action, bson.M{"target_username": target}, ""); aerr != nil { log.Printf("admin: audit user enable/disable: %v", aerr) } + resp["disabled"] = *req.Disabled w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": target, "disabled": *req.Disabled}) + _ = json.NewEncoder(w).Encode(resp) } func isValidUsername(s string) bool { diff --git a/pkg/ai/openai_compat.go b/pkg/ai/openai_compat.go index 0958ef4..430d9c0 100644 --- a/pkg/ai/openai_compat.go +++ b/pkg/ai/openai_compat.go @@ -60,8 +60,10 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri var parsed struct { Choices []struct { - Message struct { - Content string `json:"content"` + FinishReason string `json:"finish_reason"` + Message struct { + Content json.RawMessage `json:"content"` + Refusal *string `json:"refusal"` } `json:"message"` } `json:"choices"` Error *struct { @@ -77,7 +79,60 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri if len(parsed.Choices) == 0 { return "", fmt.Errorf("%s: empty response", cfg.Label) } - return strings.TrimSpace(parsed.Choices[0].Message.Content), nil + choice := parsed.Choices[0] + if choice.Message.Refusal != nil { + if s := strings.TrimSpace(*choice.Message.Refusal); s != "" { + return "", fmt.Errorf("%s: model refused: %s", cfg.Label, s) + } + } + content, err := extractOpenAIMessageContent(choice.Message.Content) + if err != nil { + return "", fmt.Errorf("parse %s response content: %w", cfg.Label, err) + } + if content == "" { + return "", fmt.Errorf("%s: %s", cfg.Label, openAICompatEmptyReplyError(choice.FinishReason, useAzureOpenAICompatMaxCompletion(cfg))) + } + return content, nil +} + +// extractOpenAIMessageContent handles OpenAI-compatible message content as a string or +// an array of {type,text} parts (Azure GPT-5 / newer SKUs may return the latter). +func extractOpenAIMessageContent(raw json.RawMessage) (string, error) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s), nil + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &parts); err == nil { + var out []string + for _, p := range parts { + if t := strings.TrimSpace(p.Text); t != "" { + out = append(out, t) + } + } + return strings.TrimSpace(strings.Join(out, "\n")), nil + } + return "", fmt.Errorf("unexpected content shape") +} + +func openAICompatEmptyReplyError(finishReason string, azureReasoning bool) string { + switch strings.ToLower(strings.TrimSpace(finishReason)) { + case "length": + if azureReasoning { + return "model returned no visible text (output token budget exhausted; reasoning tokens count toward max_completion_tokens). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt" + } + return "model returned no visible text (output token budget exhausted). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt" + case "content_filter": + return "response blocked by content filter" + default: + return "empty message content" + } } // useAzureOpenAICompatMaxCompletion is true for Azure AI Foundry / Azure OpenAI inference diff --git a/pkg/ai/openai_compat_test.go b/pkg/ai/openai_compat_test.go new file mode 100644 index 0000000..3b0969a --- /dev/null +++ b/pkg/ai/openai_compat_test.go @@ -0,0 +1,98 @@ +package ai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestExtractOpenAIMessageContent_string(t *testing.T) { + got, err := extractOpenAIMessageContent(json.RawMessage(`"hello world"`)) + if err != nil || got != "hello world" { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestExtractOpenAIMessageContent_array(t *testing.T) { + got, err := extractOpenAIMessageContent(json.RawMessage(`[{"type":"text","text":"line one"},{"type":"text","text":"line two"}]`)) + if err != nil || got != "line one\nline two" { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestExtractOpenAIMessageContent_null(t *testing.T) { + got, err := extractOpenAIMessageContent(json.RawMessage(`null`)) + if err != nil || got != "" { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestOpenAICompatEmptyReplyError_length(t *testing.T) { + msg := openAICompatEmptyReplyError("length", true) + if msg == "" || !strings.Contains(msg, "REAPER_AI_MAX_TOKENS") { + t.Fatalf("unexpected message: %q", msg) + } +} + +func TestChatOpenAICompatible_parsesArrayContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "finish_reason": "stop", + "message": map[string]interface{}{ + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "Azure reply"}}, + }, + }, + }, + }) + })) + defer srv.Close() + + cfg := ProviderSettings{ + ID: ProviderOpenAI, + Label: "OpenAI", + APIURL: srv.URL, + APIKey: "test", + Model: "gpt-4o", + MaxTokens: 256, + } + reply, err := chatOpenAICompatible(context.Background(), cfg, "sys", []Message{{Role: "user", Content: "hi"}}) + if err != nil || reply != "Azure reply" { + t.Fatalf("reply=%q err=%v", reply, err) + } +} + +func TestChatOpenAICompatible_emptyContentIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "finish_reason": "length", + "message": map[string]interface{}{ + "role": "assistant", + "content": "", + }, + }, + }, + }) + })) + defer srv.Close() + + cfg := ProviderSettings{ + ID: ProviderFoundry, + Label: "Azure AI Foundry", + APIURL: srv.URL, + APIKey: "test", + Model: "gpt-5.5", + MaxTokens: 2048, + } + _, err := chatOpenAICompatible(context.Background(), cfg, "sys", []Message{{Role: "user", Content: "hi"}}) + if err == nil { + t.Fatal("expected error for empty content") + } +} diff --git a/pkg/dbconnections/admin.go b/pkg/dbconnections/admin.go index 275f0ce..8f75467 100644 --- a/pkg/dbconnections/admin.go +++ b/pkg/dbconnections/admin.go @@ -14,6 +14,7 @@ import ( const ( collectionOperators = "operators" collectionOperatorSessions = "operator_sessions" + maxOperatorUsernameLen = 128 ) // OperatorsCollection holds admin users for the web panel. @@ -28,6 +29,56 @@ const ( RoleOperator = "operator" ) +// canonicalOperatorUsername returns an allowlisted username copy. +// Rebuilding rune-by-rune (instead of returning the trimmed input) ensures Mongo +// filters never receive a direct user-controlled string (CodeQL go/sql-injection / +// go/nosql-injection on bson.M{"username": ...}). +func canonicalOperatorUsername(username string) (string, bool) { + username = strings.TrimSpace(username) + if username == "" || len(username) > maxOperatorUsernameLen { + return "", false + } + var b strings.Builder + b.Grow(len(username)) + for _, r := range username { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-': + default: + return "", false + } + b.WriteRune(r) + } + out := b.String() + if out == "" { + return "", false + } + return out, true +} + +// operatorUsernameFilter builds an equality filter with an explicit $eq so the +// username value cannot be interpreted as a MongoDB operator document. +func operatorUsernameFilter(username string) (bson.M, bool) { + safe, ok := canonicalOperatorUsername(username) + if !ok { + return nil, false + } + return bson.M{"username": bson.M{"$eq": safe}}, true +} + +func canonicalOperatorRole(role string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(role)) { + case RoleAdmin: + return RoleAdmin, true + case RoleOperator: + return RoleOperator, true + default: + return "", false + } +} + // Operator is a human operator account for the admin panel. type Operator struct { Username string `bson:"username"` @@ -104,8 +155,12 @@ func InsertOperator(ctx context.Context, op Operator) error { // FindOperatorByUsername loads an operator by username. func FindOperatorByUsername(ctx context.Context, username string) (*Operator, error) { + filter, ok := operatorUsernameFilter(username) + if !ok { + return nil, mongo.ErrNoDocuments + } var op Operator - err := OperatorsCollection.FindOne(ctx, bson.M{"username": username}).Decode(&op) + err := OperatorsCollection.FindOne(ctx, filter).Decode(&op) if err != nil { return nil, err } @@ -161,13 +216,13 @@ func DeleteSession(ctx context.Context, token string) error { // DeleteSessionsForUsername removes all sessions for an operator (e.g. after disable). func DeleteSessionsForUsername(ctx context.Context, username string) error { - username = strings.TrimSpace(username) - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return nil } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - _, err := OperatorSessionsCollection.DeleteMany(ctx, bson.M{"username": username}) + _, err := OperatorSessionsCollection.DeleteMany(ctx, filter) return err } @@ -206,15 +261,37 @@ func CountActiveAdminsExcluding(ctx context.Context, excludeUsername string) (in return n, cur.Err() } +// SetOperatorRole sets the portal role (admin | operator). +func SetOperatorRole(ctx context.Context, username, role string) error { + filter, ok := operatorUsernameFilter(username) + if !ok { + return mongo.ErrNoDocuments + } + storedRole, ok := canonicalOperatorRole(role) + if !ok { + return mongo.ErrNoDocuments + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"role": storedRole}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return mongo.ErrNoDocuments + } + return nil +} + // SetOperatorDisabled sets disabled flag and returns mongo.ErrNoDocuments if user missing. func SetOperatorDisabled(ctx context.Context, username string, disabled bool) error { - username = strings.TrimSpace(username) - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"disabled": disabled}}) + res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"disabled": disabled}}) if err != nil { return err } @@ -226,13 +303,13 @@ func SetOperatorDisabled(ctx context.Context, username string, disabled bool) er // UpdateOperatorPasswordHash sets a new password hash for an operator. func UpdateOperatorPasswordHash(ctx context.Context, username, passwordHash string) error { - username = strings.TrimSpace(username) - if username == "" || passwordHash == "" { + filter, ok := operatorUsernameFilter(username) + if !ok || passwordHash == "" { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"password_hash": passwordHash}}) + res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"password_hash": passwordHash}}) if err != nil { return err } @@ -244,13 +321,13 @@ func UpdateOperatorPasswordHash(ctx context.Context, username, passwordHash stri // SetOperatorTotpPending stores a base32 secret for enrollment (not yet active). func SetOperatorTotpPending(ctx context.Context, username, pendingSecretBase32 string) error { - username = strings.TrimSpace(username) - if username == "" || pendingSecretBase32 == "" { + filter, ok := operatorUsernameFilter(username) + if !ok || pendingSecretBase32 == "" { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"totp_pending_secret": pendingSecretBase32}}) + res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"totp_pending_secret": pendingSecretBase32}}) if err != nil { return err } @@ -262,21 +339,21 @@ func SetOperatorTotpPending(ctx context.Context, username, pendingSecretBase32 s // ConfirmOperatorTotp promotes totp_pending_secret to totp_secret and enables MFA. func ConfirmOperatorTotp(ctx context.Context, username string) error { - username = strings.TrimSpace(username) - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() var op Operator - err := OperatorsCollection.FindOne(ctx, bson.M{"username": username}).Decode(&op) + err := OperatorsCollection.FindOne(ctx, filter).Decode(&op) if err != nil { return err } if strings.TrimSpace(op.TotpPendingSecret) == "" { return mongo.ErrNoDocuments } - _, err = OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{ + _, err = OperatorsCollection.UpdateOne(ctx, filter, bson.M{ "$set": bson.M{ "totp_secret": op.TotpPendingSecret, "totp_enabled": true, @@ -288,13 +365,13 @@ func ConfirmOperatorTotp(ctx context.Context, username string) error { // DisableOperatorTotp turns off MFA and clears secrets. func DisableOperatorTotp(ctx context.Context, username string) error { - username = strings.TrimSpace(username) - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{ + res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{ "$set": bson.M{"totp_enabled": false}, "$unset": bson.M{ "totp_secret": "", @@ -312,13 +389,13 @@ func DisableOperatorTotp(ctx context.Context, username string) error { // ClearOperatorTotpPending abandons in-progress enrollment. func ClearOperatorTotpPending(ctx context.Context, username string) error { - username = strings.TrimSpace(username) - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return mongo.ErrNoDocuments } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - _, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$unset": bson.M{"totp_pending_secret": ""}}) + _, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$unset": bson.M{"totp_pending_secret": ""}}) return err } diff --git a/pkg/dbconnections/admin_role_test.go b/pkg/dbconnections/admin_role_test.go new file mode 100644 index 0000000..3bdb595 --- /dev/null +++ b/pkg/dbconnections/admin_role_test.go @@ -0,0 +1,63 @@ +package dbconnections + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestCanonicalOperatorUsername(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want string + ok bool + }{ + {"alice", "alice", true}, + {" Alice.1_b-c ", "Alice.1_b-c", true}, + {"", "", false}, + {" ", "", false}, + {"bad$name", "", false}, + {"alice{$ne:1}", "", false}, + {`{"$ne":""}`, "", false}, + } + for _, tc := range cases { + got, ok := canonicalOperatorUsername(tc.in) + if ok != tc.ok || got != tc.want { + t.Fatalf("canonicalOperatorUsername(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +func TestCanonicalOperatorRole(t *testing.T) { + t.Parallel() + got, ok := canonicalOperatorRole(" ADMIN ") + if !ok || got != RoleAdmin { + t.Fatalf("got %q ok=%v", got, ok) + } + got, ok = canonicalOperatorRole("operator") + if !ok || got != RoleOperator { + t.Fatalf("got %q ok=%v", got, ok) + } + if _, ok := canonicalOperatorRole("root"); ok { + t.Fatal("expected reject") + } +} + +func TestOperatorUsernameFilterUsesEq(t *testing.T) { + t.Parallel() + filter, ok := operatorUsernameFilter("alice") + if !ok { + t.Fatal("expected ok") + } + inner, ok := filter["username"].(bson.M) + if !ok { + t.Fatalf("username filter type %T", filter["username"]) + } + if inner["$eq"] != "alice" { + t.Fatalf("expected $eq alice, got %#v", inner) + } + if _, ok := operatorUsernameFilter(`{"$gt":""}`); ok { + t.Fatal("operator-looking username must be rejected") + } +} diff --git a/pkg/dbconnections/audit.go b/pkg/dbconnections/audit.go index 8f71caf..f0305fd 100644 --- a/pkg/dbconnections/audit.go +++ b/pkg/dbconnections/audit.go @@ -29,6 +29,7 @@ const ( AuditActionTotpDisabled = "totp_disabled" AuditActionUserDisabled = "user_disabled" AuditActionUserEnabled = "user_enabled" + AuditActionUserRoleUpdated = "user_role_updated" AuditActionEngagementOperatorsUpdated = "engagement_operators_updated" AuditActionAIChat = "ai_chat" ) diff --git a/pkg/dbconnections/mfa_challenge.go b/pkg/dbconnections/mfa_challenge.go index d1e51e7..56260da 100644 --- a/pkg/dbconnections/mfa_challenge.go +++ b/pkg/dbconnections/mfa_challenge.go @@ -72,11 +72,12 @@ func PeekMFAChallenge(ctx context.Context, token string) (username string, err e // DeleteMFAChallengesForUser removes any pending login challenges for a user (e.g. after password change). func DeleteMFAChallengesForUser(ctx context.Context, username string) error { - if username == "" { + filter, ok := operatorUsernameFilter(username) + if !ok { return nil } ctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() - _, err := MFAChallengesCollection.DeleteMany(ctx, bson.M{"username": username}) + _, err := MFAChallengesCollection.DeleteMany(ctx, filter) return err }