feat(security): use bcrypt hashes and safe paths for boxes
- Replace legacy salted password hashing with bcrypt and store hash alg - Accept existing bcrypt hashes while keeping legacy verification fallback - Validate box IDs and use SafeChildPath for box/file operations to prevent traversal - Refactor download flow to share zip writer logic and correctly handle one-time deletes and optional renew-on-download only after a successful zip writefeat(security): use bcrypt hashes and safe paths for boxes - Replace legacy salted password hashing with bcrypt and store hash alg - Accept existing bcrypt hashes while keeping legacy verification fallback - Validate box IDs and use SafeChildPath for box/file operations to prevent traversal - Refactor download flow to share zip writer logic and correctly handle one-time deletes and optional renew-on-download only after a successful zip write
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -57,6 +58,7 @@ type adminBoxRow struct {
|
||||
|
||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(noStoreAdminHeaders)
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
admin.POST("/login", app.handleAdminLoginPost)
|
||||
|
||||
@@ -132,6 +134,7 @@ func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -267,6 +270,7 @@ func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Users": rows,
|
||||
"Tags": tags,
|
||||
"Error": errorMessage,
|
||||
@@ -330,6 +334,7 @@ func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Tags": rows,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
@@ -374,6 +379,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Rows": app.config.SettingRows(),
|
||||
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
||||
"Error": errorMessage,
|
||||
@@ -393,6 +399,11 @@ func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if !validAdminCSRF(ctx, session) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
@@ -407,6 +418,7 @@ func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||
}
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
@@ -458,6 +470,15 @@ func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
||||
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
||||
if token, ok := value.(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
@@ -465,6 +486,30 @@ func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||
})
|
||||
}
|
||||
|
||||
func noStoreAdminHeaders(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.Header("Pragma", "no-cache")
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
||||
switch ctx.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
|
||||
token := ctx.PostForm("csrf_token")
|
||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||
}
|
||||
|
||||
func subtleConstantTimeEqual(a string, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
||||
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
|
||||
const boxAuthCookiePrefix = "warpbox_box_"
|
||||
|
||||
var oneTimeDownloadLocks sync.Map
|
||||
|
||||
func (app *App) handleIndex(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"RetentionOptions": app.retentionOptions(),
|
||||
@@ -166,6 +169,10 @@ func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
app.handleOneTimeDownloadBox(ctx, boxID)
|
||||
return
|
||||
}
|
||||
|
||||
if hasManifest && manifest.DisableZip {
|
||||
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
|
||||
@@ -177,11 +184,45 @@ func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload && !allFilesComplete(files) {
|
||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
return
|
||||
}
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||
lock := oneTimeDownloadLock(boxID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
defer oneTimeDownloadLocks.Delete(boxID)
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !hasManifest || !manifest.OneTimeDownload {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if !allFilesComplete(files) {
|
||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
||||
ctx.Header("Content-Type", "application/zip")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||
|
||||
@@ -197,25 +238,24 @@ func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||
if !file.IsComplete {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
zipClosed = true
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return
|
||||
return false
|
||||
}
|
||||
zipClosed = true
|
||||
return true
|
||||
}
|
||||
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
boxstore.DeleteBox(boxID)
|
||||
} else if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
||||
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func allFilesComplete(files []models.BoxFile) bool {
|
||||
@@ -259,6 +299,10 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||
ctx.String(http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.FileAttachment(path, filename)
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
@@ -297,6 +341,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
@@ -332,6 +377,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
@@ -366,11 +412,12 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -379,6 +426,14 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
||||
return
|
||||
}
|
||||
if request.Status == models.FileStatusReady {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
|
||||
return
|
||||
}
|
||||
if err := app.rejectExpiredManifestBox(boxID); err != nil {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
|
||||
if err != nil {
|
||||
@@ -393,6 +448,7 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
@@ -423,6 +479,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
form, err := ctx.MultipartForm()
|
||||
if err != nil {
|
||||
@@ -580,14 +637,18 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return err
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return app.validateIncomingFile(boxID, size)
|
||||
}
|
||||
if boxstore.IsExpired(manifest) {
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
totalSize := int64(0)
|
||||
found := false
|
||||
for _, file := range manifest.Files {
|
||||
@@ -624,6 +685,37 @@ func (app *App) validateBoxSize(size int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) rejectExpiredManifestBox(boxID string) error {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !boxstore.IsExpired(manifest) {
|
||||
return nil
|
||||
}
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
|
||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
||||
limit := app.maxRequestBodyBytes()
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
|
||||
}
|
||||
|
||||
func (app *App) maxRequestBodyBytes() int64 {
|
||||
limit := app.config.GlobalMaxBoxSizeBytes
|
||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
||||
limit = app.config.GlobalMaxFileSizeBytes
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
|
||||
func (app *App) retentionAllowed(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
|
||||
79
lib/server/security_test.go
Normal file
79
lib/server/security_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
|
||||
restoreUploadRoot := boxstore.UploadRoot()
|
||||
defer boxstore.SetUploadRoot(restoreUploadRoot)
|
||||
boxstore.SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "0123456789abcdef0123456789abcdef"
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
manifest := models.BoxManifest{
|
||||
Files: []models.BoxFile{{ID: "0123456789abcdef", Name: "file.txt", Status: models.FileStatusWait}},
|
||||
ExpiresAt: time.Now().UTC().Add(-time.Second),
|
||||
}
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
app := &App{config: &config.Config{}}
|
||||
if err := app.validateManifestFileUpload(boxID, "0123456789abcdef", 1); err == nil {
|
||||
t.Fatal("expected expired box upload to be rejected")
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(boxID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminProtectedPostRequiresCSRF(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
store, err := metastore.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
adminTag, err := store.EnsureAdminTag()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminTag returned error: %v", err)
|
||||
}
|
||||
user, err := store.CreateUserWithPassword("admin", "", "secret", []string{adminTag.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
session, err := store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
app := &App{config: &config.Config{}, store: store}
|
||||
router := gin.New()
|
||||
router.POST("/admin/test", app.requireAdminSession, func(ctx *gin.Context) {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/test", nil)
|
||||
request.AddCookie(&http.Cookie{Name: adminSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user