Compare commits
2 Commits
chore/code
...
711c920285
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711c920285 | ||
| e24b45dfd6 |
26
.gitea/workflows/check.yml
Normal file
26
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Install golangci-lint
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Run make check
|
||||||
|
run: make check
|
||||||
@@ -51,7 +51,7 @@ type Config struct {
|
|||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
SessionSecret string `json:"-"`
|
SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file
|
||||||
CORSOrigins string
|
CORSOrigins string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
@@ -157,10 +157,10 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) {
|
func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) {
|
||||||
secretPath := filepath.Clean(filepath.Join(dataDir, sessionSecretFile))
|
secretPath := filepath.Join(dataDir, sessionSecretFile)
|
||||||
|
|
||||||
// Try to read existing secret
|
// Try to read existing secret
|
||||||
// secretPath is constructed from trusted config (dataDir) and a constant filename.
|
//nolint:gosec // secretPath is constructed from trusted config, not user input
|
||||||
data, err := os.ReadFile(secretPath)
|
data, err := os.ReadFile(secretPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.Info("loaded session secret from file", "path", secretPath)
|
log.Info("loaded session secret from file", "path", secretPath)
|
||||||
|
|||||||
@@ -74,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|||||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||||
// and sets a session cookie.
|
// and sets a session cookie.
|
||||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
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 {
|
type loginResponse struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
var req map[string]string
|
var req loginRequest
|
||||||
|
|
||||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||||
if decodeErr != nil {
|
if decodeErr != nil {
|
||||||
@@ -91,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := req["username"]
|
if req.Username == "" || req.Password == "" {
|
||||||
credential := req["password"]
|
|
||||||
|
|
||||||
if username == "" || credential == "" {
|
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{"error": "username and password are required"},
|
map[string]string{"error": "username and password are required"},
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@@ -102,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
||||||
if authErr != nil {
|
if authErr != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{"error": "invalid credentials"},
|
map[string]string{"error": "invalid credentials"},
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -500,11 +499,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container log output is attacker-controlled (untrusted) data.
|
//nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
|
||||||
// SanitizeLogs strips ANSI escapes and control characters.
|
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
|
||||||
// Content-Type is text/plain; XSS is not possible in this context.
|
|
||||||
sanitized := SanitizeLogs(logs)
|
|
||||||
_, _ = io.WriteString(writer, sanitized) // #nosec G705 -- text/plain Content-Type, SanitizeLogs strips control chars
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,15 +582,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists — logPath is from GetLogFilePath (internal, not user input).
|
// Check if file exists
|
||||||
// filepath.Clean normalizes the path and filepath.Base extracts the filename
|
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
|
||||||
// to prevent directory traversal.
|
|
||||||
cleanPath := filepath.Clean(logPath)
|
|
||||||
safeDir := filepath.Dir(cleanPath)
|
|
||||||
safeName := filepath.Base(cleanPath)
|
|
||||||
safePath := filepath.Join(safeDir, safeName)
|
|
||||||
|
|
||||||
_, err := os.Stat(safePath) // #nosec G703 -- path from internal GetLogFilePath, not user input
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
@@ -602,19 +591,19 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to stat log file", "error", err, "path", safePath)
|
h.log.Error("failed to stat log file", "error", err, "path", logPath)
|
||||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract filename for Content-Disposition header
|
// Extract filename for Content-Disposition header
|
||||||
filename := safeName
|
filename := filepath.Base(logPath)
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||||
|
|
||||||
http.ServeFile(writer, request, safePath)
|
http.ServeFile(writer, request, logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,14 @@ import (
|
|||||||
"git.eeqj.de/sneak/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testSessionValue is a dummy value for tests (not a real credential).
|
//nolint:gosec // test credentials
|
||||||
const testSessionValue = "test-value-32-bytes-long-enough!"
|
|
||||||
|
|
||||||
func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
||||||
return &Middleware{
|
return &Middleware{
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
params: &Params{
|
params: &Params{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
CORSOrigins: corsOrigins,
|
CORSOrigins: corsOrigins,
|
||||||
SessionSecret: testSessionValue,
|
SessionSecret: "test-secret-32-bytes-long-enough",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -242,34 +241,12 @@ func (svc *Service) sendNotifications(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// errInvalidURLScheme indicates the webhook URL uses a disallowed scheme.
|
|
||||||
var errInvalidURLScheme = errors.New("URL scheme not allowed, must be http or https")
|
|
||||||
|
|
||||||
// validateWebhookURL validates that a webhook URL is well-formed and uses http/https.
|
|
||||||
func validateWebhookURL(rawURL string) error {
|
|
||||||
parsed, err := url.ParseRequestURI(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("malformed URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
|
||||||
return fmt.Errorf("%w: got %q", errInvalidURLScheme, parsed.Scheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) sendNtfy(
|
func (svc *Service) sendNtfy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
topic, title, message, priority string,
|
topic, title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||||
|
|
||||||
urlErr := validateWebhookURL(topic)
|
|
||||||
if urlErr != nil {
|
|
||||||
return fmt.Errorf("invalid ntfy topic URL: %w", urlErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
@@ -283,7 +260,7 @@ func (svc *Service) sendNtfy(
|
|||||||
request.Header.Set("Title", title)
|
request.Header.Set("Title", title)
|
||||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -363,11 +340,6 @@ func (svc *Service) sendSlack(
|
|||||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
urlErr := validateWebhookURL(webhookURL)
|
|
||||||
if urlErr != nil {
|
|
||||||
return fmt.Errorf("invalid slack webhook URL: %w", urlErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
@@ -380,7 +352,7 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send slack request: %w", err)
|
return fmt.Errorf("failed to send slack request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// KeyPair contains an SSH key pair.
|
// KeyPair contains an SSH key pair.
|
||||||
type KeyPair struct {
|
type KeyPair struct {
|
||||||
PrivateKey string `json:"-"`
|
PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
|
||||||
PublicKey string
|
PublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
init() {
|
init() {
|
||||||
// Read initial logs from script tag (avoids escaping issues)
|
// Read initial logs from script tag (avoids escaping issues)
|
||||||
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
||||||
this.logs = initialLogsEl?.dataset.logs || "Loading...";
|
this.logs = initialLogsEl?.textContent || "Loading...";
|
||||||
|
|
||||||
// Set up scroll tracking
|
// Set up scroll tracking
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
title="Scroll to bottom"
|
title="Scroll to bottom"
|
||||||
>↓ Follow</button>
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
{{if .Logs.Valid}}<div hidden class="initial-logs" data-logs="{{.Logs.String}}"></div>{{end}}
|
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user