feat: bypass security for health checks and support HEAD downloads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m30s

- Allow the `/health` endpoint to bypass the security middleware, ensuring container health checks succeed even if the proxy IP is banned.
- Add a test to verify health checks from banned IPs.
- Register a HEAD route for file downloads.
- Refactor admin alert status checks to use a new `isUnacknowledgedAlert` helper.
- Update the security runbook documentation with clearer instructions and examples for trusted proxy configuration.
This commit is contained in:
2026-05-23 19:07:11 +03:00
parent a2c80ac105
commit f0dcdd50ca
10 changed files with 250 additions and 11 deletions

View File

@@ -2,26 +2,26 @@
## Trusted Proxy Setup (Caddy) ## Trusted Proxy Setup (Caddy)
Set `WARPBOX_TRUSTED_PROXY_CIDRS` to only the CIDRs of your reverse proxies/load balancers. Set `WARPBOX_TRUSTED_PROXY_CIDRS` to only the CIDRs of your reverse proxies/load balancers. Without this, WarpBox intentionally ignores forwarding headers and every request may appear to come from the proxy/container bridge, such as `172.30.0.1`.
Example: Example:
```bash ```bash
WARPBOX_TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16 WARPBOX_TRUSTED_PROXY_CIDRS=172.30.0.1/32
``` ```
Caddy example: Caddy example:
```caddyfile ```caddyfile
:443 { :443 {
reverse_proxy 127.0.0.1:8080 { reverse_proxy warpbox:8080 {
header_up X-Forwarded-For {http.request.remote.host} header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host} header_up X-Real-IP {http.request.remote.host}
} }
} }
``` ```
WarpBox will trust `X-Forwarded-For` only if the direct remote IP is inside `WARPBOX_TRUSTED_PROXY_CIDRS`. WarpBox will trust `X-Forwarded-For` only if the direct remote IP is inside `WARPBOX_TRUSTED_PROXY_CIDRS`. Prefer the exact proxy IP as a `/32` when it is stable. If Caddy is on a changing Docker/Podman network, use that network's CIDR instead. You can find it with `docker network inspect <network>` or `podman network inspect <network>`.
## IP Ban Operations ## IP Ban Operations

View File

@@ -57,6 +57,7 @@ func Register(router *gin.Engine, handlers Handlers) {
router.GET("/box/:id/download", handlers.DownloadBox) router.GET("/box/:id/download", handlers.DownloadBox)
router.GET("/box/:id/files/:filename", handlers.DownloadFile) router.GET("/box/:id/files/:filename", handlers.DownloadFile)
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail) router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
router.HEAD("/box/:id/files/:filename", handlers.DownloadFile)
router.POST("/box", handlers.CreateBox) router.POST("/box", handlers.CreateBox)
router.POST("/box/:id/login", handlers.BoxLoginPost) router.POST("/box/:id/login", handlers.BoxLoginPost)

View File

@@ -287,7 +287,7 @@ func (app *App) buildAdminDashboardView() adminDashboardView {
} }
for _, alert := range alertsList { for _, alert := range alertsList {
if alert.Status != alerts.StatusClosed { if isUnacknowledgedAlert(alert) {
view.OpenAlerts++ view.OpenAlerts++
switch alert.Severity { switch alert.Severity {
case "high": case "high":
@@ -474,10 +474,10 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
case "closed": case "closed":
closedCount++ closedCount++
} }
if alert.Severity == "high" && string(alert.Status) != "closed" { if alert.Severity == "high" && isUnacknowledgedAlert(alert) {
highCount++ highCount++
} }
if alert.Severity == "medium" && string(alert.Status) != "closed" { if alert.Severity == "medium" && isUnacknowledgedAlert(alert) {
mediumCount++ mediumCount++
} }
} }
@@ -495,3 +495,7 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
"AlertChipLabel": adminAlertChipLabel(openCount), "AlertChipLabel": adminAlertChipLabel(openCount),
}) })
} }
func isUnacknowledgedAlert(alert alerts.Alert) bool {
return alert.Status == "" || alert.Status == alerts.StatusOpen
}

View File

@@ -0,0 +1,38 @@
package server
import (
"path/filepath"
"testing"
"warpbox/lib/alerts"
"warpbox/lib/config"
)
func TestAdminDashboardCountsOnlyUnacknowledgedAlerts(t *testing.T) {
store := alerts.NewStore(filepath.Join(t.TempDir(), "alerts.json"))
for _, alert := range []alerts.Alert{
{ID: "open-high", Title: "Open high", Severity: "high", Status: alerts.StatusOpen},
{ID: "acked-high", Title: "Acked high", Severity: "high", Status: alerts.StatusAcked},
{ID: "closed-medium", Title: "Closed medium", Severity: "medium", Status: alerts.StatusClosed},
} {
if err := store.Add(alert); err != nil {
t.Fatalf("Add returned error: %v", err)
}
}
app := &App{
config: &config.Config{},
alertStore: store,
}
view := app.buildAdminDashboardView()
if view.OpenAlerts != 1 {
t.Fatalf("expected only unacknowledged alerts in dashboard count, got %d", view.OpenAlerts)
}
if view.HighAlerts != 1 || view.MediumAlerts != 0 || view.LowAlerts != 0 {
t.Fatalf("expected only open alert severities, got high=%d medium=%d low=%d", view.HighAlerts, view.MediumAlerts, view.LowAlerts)
}
if len(view.Alerts) != 1 || view.Alerts[0].ID != "open-high" {
t.Fatalf("expected only open alert in dashboard inbox, got %#v", view.Alerts)
}
}

View File

@@ -103,6 +103,10 @@ func (app *App) createAlert(title string, severity string, group string, code st
func (app *App) securityMiddleware() gin.HandlerFunc { func (app *App) securityMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
if ctx.Request != nil && ctx.Request.URL != nil && ctx.Request.URL.Path == "/health" {
ctx.Next()
return
}
if !app.securityFeaturesEnabled() { if !app.securityFeaturesEnabled() {
ctx.Next() ctx.Next()
return return

View File

@@ -16,6 +16,27 @@ import (
"warpbox/lib/security" "warpbox/lib/security"
) )
func TestSecurityMiddlewareAllowsHealthCheckFromBannedIP(t *testing.T) {
app := &App{
config: &config.Config{SecurityEnabled: true},
securityGuard: security.NewGuard(),
}
app.securityGuard.Ban("172.30.0.1", 300)
router := gin.New()
router.Use(app.securityMiddleware())
router.GET("/health", app.handleHealth)
request := httptest.NewRequest(http.MethodGet, "/health", nil)
request.RemoteAddr = "172.30.0.1:12345"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected health check to pass, got %d", response.Code)
}
}
func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) { func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) {
app, router := setupAdminSecurityTest(t) app, router := setupAdminSecurityTest(t)

View File

@@ -4,8 +4,10 @@ import (
"archive/zip" "archive/zip"
"fmt" "fmt"
"io" "io"
"mime"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -233,7 +235,8 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
return return
} }
if _, err := os.Stat(path); err != nil { info, err := os.Stat(path)
if err != nil {
ctx.String(http.StatusNotFound, "File not found") ctx.String(http.StatusNotFound, "File not found")
return return
} }
@@ -242,12 +245,49 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
return return
} }
ctx.FileAttachment(path, filename) if !app.serveDownloadFile(ctx, path, filename, info) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled { if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs) boxstore.RenewManifest(boxID, manifest.RetentionSecs)
} }
} }
func (app *App) serveDownloadFile(ctx *gin.Context, path string, filename string, info os.FileInfo) bool {
file, err := os.Open(path)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not read file")
return false
}
defer file.Close()
mimeType := helpers.MimeTypeForFile(path, filename)
ctx.Header("Content-Type", mimeType)
ctx.Header("Content-Disposition", contentDispositionForDownload(filename, mimeType))
ctx.Header("X-Content-Type-Options", "nosniff")
http.ServeContent(ctx.Writer, ctx.Request, filename, info.ModTime(), file)
return true
}
func contentDispositionForDownload(filename string, mimeType string) string {
disposition := "attachment"
if isEmbeddableMimeType(mimeType) {
disposition = "inline"
}
return mime.FormatMediaType(disposition, map[string]string{"filename": filename})
}
func isEmbeddableMimeType(mimeType string) bool {
baseType := strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0]))
if strings.HasPrefix(baseType, "video/") || strings.HasPrefix(baseType, "audio/") {
return true
}
if strings.HasPrefix(baseType, "image/") {
return baseType != "image/svg+xml"
}
return baseType == "application/pdf" || baseType == "text/plain"
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) { func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id") boxID := ctx.Param("id")
fileID := ctx.Param("file_id") fileID := ctx.Param("file_id")

View File

@@ -0,0 +1,120 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/models"
)
const downloadTestBoxID = "abcdefabcdefabcdefabcdefabcdefab"
func TestDownloadFileServesEmbeddableMediaInlineWithRangeSupport(t *testing.T) {
app := setupDownloadFileTest(t, "clip.mp4", []byte("0123456789"))
response := performDownloadFile(app, http.MethodGet, "/box/"+downloadTestBoxID+"/files/clip.mp4", map[string]string{
"Range": "bytes=0-3",
})
if response.Code != http.StatusPartialContent {
t.Fatalf("expected ranged download to return 206, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline;") || !strings.Contains(got, "filename=clip.mp4") {
t.Fatalf("expected inline content disposition for embeddable media, got %q", got)
}
if got := response.Header().Get("Content-Type"); !strings.HasPrefix(got, "video/mp4") {
t.Fatalf("expected video content type, got %q", got)
}
if got := response.Header().Get("Content-Range"); got != "bytes 0-3/10" {
t.Fatalf("expected byte range header, got %q", got)
}
if got := response.Body.String(); got != "0123" {
t.Fatalf("expected ranged body, got %q", got)
}
}
func TestDownloadFileServesUnsafeInlineTypesAsAttachments(t *testing.T) {
app := setupDownloadFileTest(t, "page.html", []byte("<!doctype html><script>alert(1)</script>"))
response := performDownloadFile(app, http.MethodGet, "/box/"+downloadTestBoxID+"/files/page.html", nil)
if response.Code != http.StatusOK {
t.Fatalf("expected download to return 200, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment;") || !strings.Contains(got, "filename=page.html") {
t.Fatalf("expected attachment content disposition for html, got %q", got)
}
}
func TestDownloadFileSupportsHeadRequests(t *testing.T) {
app := setupDownloadFileTest(t, "clip.mp4", []byte("0123456789"))
response := performDownloadFile(app, http.MethodHead, "/box/"+downloadTestBoxID+"/files/clip.mp4", nil)
if response.Code != http.StatusOK {
t.Fatalf("expected HEAD download to return 200, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline;") {
t.Fatalf("expected inline content disposition for HEAD request, got %q", got)
}
if response.Body.Len() != 0 {
t.Fatalf("expected HEAD response body to be empty, got %d bytes", response.Body.Len())
}
}
func setupDownloadFileTest(t *testing.T, filename string, body []byte) *App {
t.Helper()
gin.SetMode(gin.TestMode)
restoreUploadRoot := boxstore.UploadRoot()
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
boxstore.SetUploadRoot(t.TempDir())
if err := os.MkdirAll(boxstore.BoxPath(downloadTestBoxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
path, ok := boxstore.SafeBoxFilePath(downloadTestBoxID, filename)
if !ok {
t.Fatal("SafeBoxFilePath rejected test file")
}
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
manifest := models.BoxManifest{
Files: []models.BoxFile{{
ID: "0123456789abcdef",
Name: filename,
Size: int64(len(body)),
MimeType: "",
Status: models.FileStatusReady,
}},
CreatedAt: time.Now().UTC(),
}
if err := boxstore.WriteManifest(downloadTestBoxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
return &App{config: &config.Config{}}
}
func performDownloadFile(app *App, method string, path string, headers map[string]string) *httptest.ResponseRecorder {
router := gin.New()
router.GET("/box/:id/files/:filename", app.handleDownloadFile)
router.HEAD("/box/:id/files/:filename", app.handleDownloadFile)
request := httptest.NewRequest(method, path, nil)
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

View File

@@ -32,6 +32,17 @@ func TestClientIPTrustedProxyChain(t *testing.T) {
} }
} }
func TestClientIPTrustedDockerBridgeProxy(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "172.30.0.1/32"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "172.30.0.1:8080"
ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55")
if got := app.clientIP(ctx); got != "198.51.100.55" {
t.Fatalf("expected forwarded client IP from trusted docker bridge, got %q", got)
}
}
func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) { func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}} app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx, _ := gin.CreateTestContext(httptest.NewRecorder())

View File

@@ -141,10 +141,10 @@
<div class="security-panel-header"><strong>Security Runbook</strong><span>ops quick reference</span></div> <div class="security-panel-header"><strong>Security Runbook</strong><span>ops quick reference</span></div>
<div class="security-panel-body security-docs"> <div class="security-panel-body security-docs">
<h4>Reverse Proxy and Trusted CIDRs</h4> <h4>Reverse Proxy and Trusted CIDRs</h4>
<p>Set <code>WARPBOX_TRUSTED_PROXY_CIDRS</code> to the CIDRs of your proxy nodes only. WarpBox will trust forwarding headers only when the direct remote IP is in this list.</p> <p>Set <code>WARPBOX_TRUSTED_PROXY_CIDRS</code> to the CIDRs of your proxy nodes only. Without this, all traffic can appear as the proxy or bridge IP, such as <code>172.30.0.1</code>.</p>
<pre>Caddyfile <pre>Caddyfile
:443 { :443 {
reverse_proxy 127.0.0.1:8080 { reverse_proxy warpbox:8080 {
header_up X-Forwarded-For {http.request.remote.host} header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host} header_up X-Real-IP {http.request.remote.host}
} }