security: FileServer static serving (path traversal fix), 5s refresh cooldown, dockerignore

This commit is contained in:
2026-08-01 03:29:20 +02:00
parent dea6447dd9
commit a32c9ed615
4 changed files with 36 additions and 21 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
node_modules
bin
dist
data.volume
*.local*
assets
+7 -2
View File
@@ -42,7 +42,8 @@ statuspage
## Requirements
- Go (recent, 1.22+ for `http.ServeMux` patterns).
- Go 1.26+ (see the `go` directive in `go.mod`; the backend uses `http.ServeMux`
method patterns, added in 1.22).
- A C compiler for `mattn/go-sqlite3` (the `CGO_ENABLED=1` build).
- `bun` (or `npm`) for the frontend build.
@@ -64,8 +65,12 @@ Then open `http://localhost:5000`.
### Docker
Clone the repo and set up your config inside it:
```sh
cp config.local.yaml config.local.yaml # your config goes here
git clone https://git.phc.dm.unipi.it/aziis98/statuspage
cd statuspage
cp example.config.yaml config.local.yaml # your config goes here
docker compose up -d --build
```
+4 -19
View File
@@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/joho/godotenv"
"github.com/spf13/pflag"
@@ -32,22 +31,8 @@ func buildFrontend() error {
return c.Run()
}
func spaHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
rel := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if rel != "." && rel != "" {
p := filepath.Join("dist", rel)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
http.ServeFile(w, r, p)
return
}
}
http.ServeFile(w, r, filepath.Join("dist", "index.html"))
}
func spaHandler() http.Handler {
return http.FileServer(http.Dir("dist"))
}
func main() {
@@ -78,7 +63,7 @@ func main() {
mux.HandleFunc("GET /api/metrics/{machine}", mm.metricsHandler)
mux.HandleFunc("POST /api/refresh/{machine}", mm.refreshHandler)
if !*devServer {
mux.HandleFunc("GET /", spaHandler())
mux.Handle("GET /", spaHandler())
}
log.Printf("listening on %s (mock)", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil {
@@ -118,7 +103,7 @@ func main() {
mux.HandleFunc("GET /api/metrics/{machine}", mon.metricsHandler)
mux.HandleFunc("POST /api/refresh/{machine}", mon.refreshHandler)
if !*devServer {
mux.HandleFunc("GET /", spaHandler())
mux.Handle("GET /", spaHandler())
}
log.Printf("listening on %s", *addr)
+18
View File
@@ -31,6 +31,9 @@ const checkNA = "na"
const maxIPHistory = 10
// minimum interval between interactive refresh requests for the same machine
const refreshCooldown = 5 * time.Second
type sshResultState struct {
Ok bool `json:"ok"`
Result string `json:"result"`
@@ -125,6 +128,9 @@ type Monitor struct {
statesMu sync.RWMutex
states map[string]*machineState
refreshMu sync.Mutex
lastRefresh map[string]time.Time
}
func NewMonitor(cfg *Config, history *History) *Monitor {
@@ -136,6 +142,7 @@ func NewMonitor(cfg *Config, history *History) *Monitor {
ctx: context.Background(),
history: history,
states: make(map[string]*machineState, len(cfg.Machines)),
lastRefresh: make(map[string]time.Time, len(cfg.Machines)),
}
for _, mc := range cfg.Machines {
m.states[mc.ID] = &machineState{status: statusUnknown, icmp: checkNA, tcp: checkNA}
@@ -765,6 +772,17 @@ func (m *Monitor) refreshHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "machine not found", http.StatusNotFound)
return
}
m.refreshMu.Lock()
if now := time.Now(); now.Sub(m.lastRefresh[id]) < refreshCooldown {
m.refreshMu.Unlock()
http.Error(w, "refresh cooldown active", http.StatusTooManyRequests)
return
} else {
m.lastRefresh[id] = now
}
m.refreshMu.Unlock()
log.Printf("interactive refresh scheduled for %s (%s)", mc.Name, mc.Host)
m.Refresh(mc)
w.WriteHeader(http.StatusAccepted)