Compare commits
2 Commits
a44f72e106
...
711c920285
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711c920285 | ||
| e24b45dfd6 |
@ -17,7 +17,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
@ -51,7 +51,7 @@ type Config struct {
|
||||
MaintenanceMode bool
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
SessionSecret string `json:"-"`
|
||||
SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file
|
||||
CORSOrigins string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
|
||||
@ -74,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||
// and sets a session cookie.
|
||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
var req map[string]string
|
||||
var req loginRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
@ -91,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username := req["username"]
|
||||
credential := req["password"]
|
||||
|
||||
if username == "" || credential == "" {
|
||||
if req.Username == "" || req.Password == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "username and password are required"},
|
||||
http.StatusBadRequest)
|
||||
@ -102,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
||||
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
||||
if authErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid credentials"},
|
||||
|
||||
@ -499,7 +499,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = writer.Write([]byte(logs)) // #nosec G705 -- Content-Type is text/plain, no XSS risk
|
||||
//nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
|
||||
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -534,7 +535,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
||||
|
||||
logs := ""
|
||||
if deployment.Logs.Valid {
|
||||
logs = deployment.Logs.String
|
||||
logs = SanitizeLogs(deployment.Logs.String)
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
@ -581,8 +582,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists — logPath is constructed internally, not from user input
|
||||
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
|
||||
// Check if file exists
|
||||
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
@ -661,7 +662,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"logs": logs,
|
||||
"logs": SanitizeLogs(logs),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
30
internal/handlers/sanitize.go
Normal file
30
internal/handlers/sanitize.go
Normal file
@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes).
|
||||
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`)
|
||||
|
||||
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
|
||||
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
|
||||
// are preserved. This ensures that attacker-controlled container output cannot
|
||||
// inject terminal escape sequences or other dangerous control characters.
|
||||
func SanitizeLogs(input string) string {
|
||||
// Strip ANSI escape sequences
|
||||
result := ansiEscapePattern.ReplaceAllString(input, "")
|
||||
|
||||
// Strip remaining non-printable characters (keep \n, \r, \t)
|
||||
var b strings.Builder
|
||||
b.Grow(len(result))
|
||||
|
||||
for _, r := range result {
|
||||
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
84
internal/handlers/sanitize_test.go
Normal file
84
internal/handlers/sanitize_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "hello world\n",
|
||||
expected: "hello world\n",
|
||||
},
|
||||
{
|
||||
name: "strips ANSI color codes",
|
||||
input: "\x1b[31mERROR\x1b[0m: something failed\n",
|
||||
expected: "ERROR: something failed\n",
|
||||
},
|
||||
{
|
||||
name: "strips OSC sequences",
|
||||
input: "\x1b]0;window title\x07normal text\n",
|
||||
expected: "normal text\n",
|
||||
},
|
||||
{
|
||||
name: "strips null bytes",
|
||||
input: "hello\x00world\n",
|
||||
expected: "helloworld\n",
|
||||
},
|
||||
{
|
||||
name: "strips bell characters",
|
||||
input: "alert\x07here\n",
|
||||
expected: "alerthere\n",
|
||||
},
|
||||
{
|
||||
name: "preserves tabs",
|
||||
input: "field1\tfield2\tfield3\n",
|
||||
expected: "field1\tfield2\tfield3\n",
|
||||
},
|
||||
{
|
||||
name: "preserves carriage returns",
|
||||
input: "line1\r\nline2\r\n",
|
||||
expected: "line1\r\nline2\r\n",
|
||||
},
|
||||
{
|
||||
name: "strips mixed escape sequences",
|
||||
input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n",
|
||||
expected: "2024-01-01 INFO starting\n",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only control characters",
|
||||
input: "\x00\x01\x02\x03",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "cursor movement sequences stripped",
|
||||
input: "\x1b[2J\x1b[H\x1b[3Atext\n",
|
||||
expected: "text\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handlers.SanitizeLogs(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@ -248,15 +247,10 @@ func (svc *Service) sendNtfy(
|
||||
) error {
|
||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||
|
||||
parsedURL, err := url.ParseRequestURI(topic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ntfy topic URL: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
parsedURL.String(),
|
||||
topic,
|
||||
bytes.NewBufferString(message),
|
||||
)
|
||||
if err != nil {
|
||||
@ -266,7 +260,7 @@ func (svc *Service) sendNtfy(
|
||||
request.Header.Set("Title", title)
|
||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
||||
|
||||
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
|
||||
resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||
}
|
||||
@ -346,15 +340,10 @@ func (svc *Service) sendSlack(
|
||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid slack webhook URL: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
parsedWebhookURL.String(),
|
||||
webhookURL,
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
@ -363,7 +352,7 @@ func (svc *Service) sendSlack(
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
|
||||
resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send slack request: %w", err)
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
|
||||
// KeyPair contains an SSH key pair.
|
||||
type KeyPair struct {
|
||||
PrivateKey string `json:"-"`
|
||||
PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user