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:
2026-04-28 21:42:36 +03:00
parent a5d6d69be0
commit cb026d4fd1
15 changed files with 545 additions and 68 deletions

View File

@@ -18,6 +18,8 @@ import (
"sync" "sync"
"time" "time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers" "warpbox/lib/helpers"
"warpbox/lib/models" "warpbox/lib/models"
) )
@@ -76,16 +78,39 @@ func BoxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID) return filepath.Join(uploadRoot, boxID)
} }
func safeBoxPath(boxID string) (string, bool) {
if !ValidBoxID(boxID) {
return "", false
}
return helpers.SafeChildPath(uploadRoot, boxID)
}
func ManifestPath(boxID string) string { func ManifestPath(boxID string) string {
return filepath.Join(BoxPath(boxID), manifestFile) return filepath.Join(BoxPath(boxID), manifestFile)
} }
func SafeBoxFilePath(boxID string, filename string) (string, bool) { func SafeBoxFilePath(boxID string, filename string) (string, bool) {
return helpers.SafeChildPath(BoxPath(boxID), filename) boxPath, ok := safeBoxPath(boxID)
if !ok {
return "", false
}
return helpers.SafeChildPath(boxPath, filename)
}
func IsSafeRegularBoxFile(boxID string, filename string) bool {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return false
}
return ensureRegularFile(path) == nil
} }
func DeleteBox(boxID string) error { func DeleteBox(boxID string) error {
return os.RemoveAll(BoxPath(boxID)) boxPath, ok := safeBoxPath(boxID)
if !ok {
return fmt.Errorf("Invalid box id")
}
return os.RemoveAll(boxPath)
} }
func ListBoxSummaries() ([]models.BoxSummary, error) { func ListBoxSummaries() ([]models.BoxSummary, error) {
@@ -218,18 +243,17 @@ func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.Box
} }
if password := strings.TrimSpace(request.Password); password != "" { if password := strings.TrimSpace(request.Password); password != "" {
salt, err := helpers.RandomHexID(16)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
authToken, err := helpers.RandomHexID(16) authToken, err := helpers.RandomHexID(16)
if err != nil { if err != nil {
return nil, fmt.Errorf("Could not secure upload box") return nil, fmt.Errorf("Could not secure upload box")
} }
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
manifest.PasswordSalt = salt manifest.PasswordHash = string(passwordHash)
manifest.PasswordHash = passwordHash(salt, password) manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = authToken manifest.AuthToken = authToken
} }
@@ -250,7 +274,7 @@ func IsExpired(manifest models.BoxManifest) bool {
} }
func IsPasswordProtected(manifest models.BoxManifest) bool { func IsPasswordProtected(manifest models.BoxManifest) bool {
return manifest.PasswordSalt != "" && manifest.PasswordHash != "" && manifest.AuthToken != "" return manifest.PasswordHash != "" && manifest.AuthToken != ""
} }
func VerifyPassword(manifest models.BoxManifest, password string) bool { func VerifyPassword(manifest models.BoxManifest, password string) bool {
@@ -259,7 +283,11 @@ func VerifyPassword(manifest models.BoxManifest, password string) bool {
} }
expected := manifest.PasswordHash expected := manifest.PasswordHash
actual := passwordHash(manifest.PasswordSalt, password) if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
}
actual := legacyPasswordHash(manifest.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
} }
@@ -335,13 +363,25 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
} }
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
source, err := os.Open(filepath.Join(BoxPath(boxID), filename)) path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return fmt.Errorf("Invalid file")
}
if err := ensureRegularFile(path); err != nil {
return err
}
zipName, ok := safeZipEntryName(filename)
if !ok {
return fmt.Errorf("Invalid zip entry")
}
source, err := os.Open(path)
if err != nil { if err != nil {
return err return err
} }
defer source.Close() defer source.Close()
destination, err := zipWriter.Create(filename) destination, err := zipWriter.Create(zipName)
if err != nil { if err != nil {
return err return err
} }
@@ -358,6 +398,9 @@ func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader)
if err != nil { if err != nil {
return models.BoxFile{}, err return models.BoxFile{}, err
} }
if IsExpired(manifest) {
return models.BoxFile{}, fmt.Errorf("Box expired")
}
fileIndex := -1 fileIndex := -1
for index, manifestFile := range manifest.Files { for index, manifestFile := range manifest.Files {
@@ -376,7 +419,10 @@ func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader)
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box") return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
} }
destination := filepath.Join(BoxPath(boxID), filename) destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil { if err := saveMultipartFile(file, destination); err != nil {
manifest.Files[fileIndex].Status = models.FileStatusFailed manifest.Files[fileIndex].Status = models.FileStatusFailed
startRetentionIfTerminalUnlocked(&manifest) startRetentionIfTerminalUnlocked(&manifest)
@@ -407,7 +453,10 @@ func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error
} }
filename = helpers.UniqueFilename(boxPath, filename) filename = helpers.UniqueFilename(boxPath, filename)
destination := filepath.Join(boxPath, filename) destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil { if err := saveMultipartFile(file, destination); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file") return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
} }
@@ -423,7 +472,9 @@ func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error
func DecorateFile(boxID string, file models.BoxFile) models.BoxFile { func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
if file.MimeType == "" { if file.MimeType == "" {
file.MimeType = helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), file.Name), file.Name) if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
}
} }
if file.SizeLabel == "" { if file.SizeLabel == "" {
@@ -495,9 +546,12 @@ func reconcileManifest(boxID string) (models.BoxManifest, error) {
changed := false changed := false
for index, file := range manifest.Files { for index, file := range manifest.Files {
path := filepath.Join(BoxPath(boxID), file.Name) path, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil || !info.Mode().IsRegular() {
continue continue
} }
@@ -531,7 +585,7 @@ func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
files := make([]models.BoxFile, 0, len(entries)) files := make([]models.BoxFile, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || entry.Name() == manifestFile { if entry.IsDir() || entry.Name() == manifestFile || entry.Type()&os.ModeSymlink != 0 {
continue continue
} }
@@ -539,6 +593,9 @@ func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !info.Mode().IsRegular() {
continue
}
name := entry.Name() name := entry.Name()
files = append(files, DecorateFile(boxID, models.BoxFile{ files = append(files, DecorateFile(boxID, models.BoxFile{
@@ -601,7 +658,7 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
} }
func passwordHash(salt string, password string) string { func legacyPasswordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password)) sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
@@ -624,12 +681,64 @@ func saveMultipartFile(file *multipart.FileHeader, destination string) error {
} }
defer source.Close() defer source.Close()
target, err := os.Create(destination) target, tempPath, err := createTempSibling(destination)
if err != nil { if err != nil {
return err return err
} }
defer target.Close() committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
_, err = io.Copy(target, source) if _, err := io.Copy(target, source); err != nil {
return err return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, destination); err != nil {
return err
}
committed = true
return nil
}
func createTempSibling(destination string) (*os.File, string, error) {
directory := filepath.Dir(destination)
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, "", err
}
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
if err != nil {
return nil, "", err
}
return target, target.Name(), nil
}
func safeZipEntryName(filename string) (string, bool) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.IsAbs(filename) {
return "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filename))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
return "", false
}
return cleaned, true
}
func ensureRegularFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("Invalid file")
}
return nil
} }

View File

@@ -1,6 +1,10 @@
package boxstore package boxstore
import ( import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -59,3 +63,87 @@ func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
t.Fatalf("expected one-time download box to avoid retention expiry, got %s", manifest.ExpiresAt) t.Fatalf("expected one-time download box to avoid retention expiry, got %s", manifest.ExpiresAt)
} }
} }
func TestSafeBoxFilePathRejectsTraversal(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if _, ok := SafeBoxFilePath(boxID, "../outside.txt"); ok {
t.Fatal("expected traversal to be rejected")
}
if _, ok := SafeBoxFilePath("../bad", "file.txt"); ok {
t.Fatal("expected invalid box id to be rejected")
}
}
func TestAddFileToZipRejectsUnsafeManifestName(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
if err := AddFileToZip(zipWriter, "0123456789abcdef0123456789abcdef", "../outside.txt"); err == nil {
t.Fatal("expected unsafe zip filename to be rejected")
}
}
func TestListFilesSkipsSymlinks(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(BoxPath(boxID), "safe.txt"), []byte("safe"), 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := os.Symlink(filepath.Join(BoxPath(boxID), "safe.txt"), filepath.Join(BoxPath(boxID), "link.txt")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
files, err := ListFiles(boxID)
if err != nil {
t.Fatalf("ListFiles returned error: %v", err)
}
if len(files) != 1 || files[0].Name != "safe.txt" {
t.Fatalf("expected only regular file, got %#v", files)
}
}
func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if _, err := CreateManifest(boxID, models.CreateBoxRequest{Password: "secret"}); err != nil {
t.Fatalf("CreateManifest returned error: %v", err)
}
manifest, err := ReadManifest(boxID)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if manifest.PasswordHashAlg != "bcrypt" {
t.Fatalf("expected bcrypt password hash, got %q", manifest.PasswordHashAlg)
}
if !VerifyPassword(manifest, "secret") {
t.Fatal("expected bcrypt password to verify")
}
legacy := models.BoxManifest{
PasswordSalt: "salt",
PasswordHash: legacyPasswordHash("salt", "secret"),
AuthToken: "token",
}
if !VerifyPassword(legacy, "secret") {
t.Fatal("expected legacy password hash to verify")
}
}

View File

@@ -140,7 +140,15 @@ func canGenerateThumbnail(file models.BoxFile) bool {
} }
func generateThumbnail(task thumbnailTask) error { func generateThumbnail(task thumbnailTask) error {
source, err := os.Open(filepath.Join(BoxPath(task.BoxID), task.Name)) sourcePath, ok := SafeBoxFilePath(task.BoxID, task.Name)
if !ok {
return os.ErrInvalid
}
if err := ensureRegularFile(sourcePath); err != nil {
return err
}
source, err := os.Open(sourcePath)
if err != nil { if err != nil {
return err return err
} }
@@ -161,15 +169,28 @@ func generateThumbnail(task thumbnailTask) error {
return os.ErrInvalid return os.ErrInvalid
} }
target, err := os.Create(path) target, tempPath, err := createTempSibling(path)
if err != nil { if err != nil {
return err return err
} }
defer target.Close() committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil { if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
return err return err
} }
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
committed = true
return markThumbnailReady(task.BoxID, task.FileID) return markThumbnailReady(task.BoxID, task.FileID)
} }

View File

@@ -14,8 +14,19 @@ func SafeFilename(name string) (string, bool) {
} }
func SafeChildPath(parent string, filename string) (string, bool) { func SafeChildPath(parent string, filename string) (string, bool) {
path := filepath.Join(parent, filename) parent = filepath.Clean(parent)
return path, strings.HasPrefix(path, parent+string(filepath.Separator)) filename = strings.TrimSpace(filename)
if parent == "" || filename == "" || filepath.IsAbs(filename) {
return "", false
}
path := filepath.Clean(filepath.Join(parent, filename))
relative, err := filepath.Rel(parent, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." {
return "", false
}
return path, true
} }
func UniqueFilename(directory string, filename string) string { func UniqueFilename(directory string, filename string) string {

20
lib/helpers/paths_test.go Normal file
View File

@@ -0,0 +1,20 @@
package helpers
import (
"path/filepath"
"testing"
)
func TestSafeChildPathRejectsTraversalAndAbsolutePaths(t *testing.T) {
parent := filepath.Join(t.TempDir(), "parent")
if _, ok := SafeChildPath(parent, "../outside.txt"); ok {
t.Fatal("expected traversal to be rejected")
}
if _, ok := SafeChildPath(parent, filepath.Join(string(filepath.Separator), "tmp", "outside.txt")); ok {
t.Fatal("expected absolute path to be rejected")
}
if path, ok := SafeChildPath(parent, "inside.txt"); !ok || path != filepath.Join(parent, "inside.txt") {
t.Fatalf("expected safe child path, got path=%q ok=%v", path, ok)
}
}

View File

@@ -46,6 +46,7 @@ type TagPermissions struct {
type Session struct { type Session struct {
Token string `json:"token"` Token string `json:"token"`
CSRFToken string `json:"csrf_token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`

View File

@@ -23,9 +23,14 @@ func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, er
if err != nil { if err != nil {
return Session{}, err return Session{}, err
} }
csrfToken, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC() now := time.Now().UTC()
session := Session{ session := Session{
Token: token, Token: token,
CSRFToken: csrfToken,
UserID: userID, UserID: userID,
CreatedAt: now, CreatedAt: now,
ExpiresAt: now.Add(ttl), ExpiresAt: now.Add(ttl),

View File

@@ -49,6 +49,7 @@ type BoxManifest struct {
RetentionSecs int64 `json:"retention_seconds"` RetentionSecs int64 `json:"retention_seconds"`
PasswordSalt string `json:"password_salt,omitempty"` PasswordSalt string `json:"password_salt,omitempty"`
PasswordHash string `json:"password_hash,omitempty"` PasswordHash string `json:"password_hash,omitempty"`
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
AuthToken string `json:"auth_token,omitempty"` AuthToken string `json:"auth_token,omitempty"`
DisableZip bool `json:"disable_zip,omitempty"` DisableZip bool `json:"disable_zip,omitempty"`
OneTimeDownload bool `json:"one_time_download,omitempty"` OneTimeDownload bool `json:"one_time_download,omitempty"`

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"crypto/subtle"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -57,6 +58,7 @@ type adminBoxRow struct {
func (app *App) registerAdminRoutes(router *gin.Engine) { func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin") admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin) admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost) admin.POST("/login", app.handleAdminLoginPost)
@@ -132,6 +134,7 @@ func (app *App) handleAdminLogout(ctx *gin.Context) {
func (app *App) handleAdminDashboard(ctx *gin.Context) { func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{ ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx), "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{ ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx), "CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows, "Users": rows,
"Tags": tags, "Tags": tags,
"Error": errorMessage, "Error": errorMessage,
@@ -330,6 +334,7 @@ func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
} }
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{ ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx), "CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows, "Tags": rows,
"Error": errorMessage, "Error": errorMessage,
}) })
@@ -374,6 +379,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) { func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{ ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx), "CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(), "Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride, "OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage, "Error": errorMessage,
@@ -393,6 +399,11 @@ func (app *App) requireAdminSession(ctx *gin.Context) {
ctx.Abort() ctx.Abort()
return return
} }
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID) user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled { if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login") ctx.Redirect(http.StatusSeeOther, "/admin/login")
@@ -407,6 +418,7 @@ func (app *App) requireAdminSession(ctx *gin.Context) {
} }
ctx.Set("adminUser", user) ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms) ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next() ctx.Next()
} }
@@ -458,6 +470,15 @@ func (app *App) currentAdminUsername(ctx *gin.Context) string {
return "" 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) { func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{ ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled, "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) { func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes")) maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil { if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -18,6 +19,8 @@ import (
const boxAuthCookiePrefix = "warpbox_box_" const boxAuthCookiePrefix = "warpbox_box_"
var oneTimeDownloadLocks sync.Map
func (app *App) handleIndex(ctx *gin.Context) { func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{ ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": app.retentionOptions(), "RetentionOptions": app.retentionOptions(),
@@ -166,6 +169,10 @@ func (app *App) handleDownloadBox(ctx *gin.Context) {
if !ok { if !ok {
return return
} }
if hasManifest && manifest.OneTimeDownload {
app.handleOneTimeDownloadBox(ctx, boxID)
return
}
if hasManifest && manifest.DisableZip { if hasManifest && manifest.DisableZip {
ctx.String(http.StatusForbidden, "Zip download disabled for this box") 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") ctx.String(http.StatusNotFound, "Box not found")
return return
} }
if hasManifest && manifest.OneTimeDownload && !allFilesComplete(files) { if !app.writeBoxZip(ctx, boxID, files) {
ctx.String(http.StatusConflict, "Box is not ready yet") 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 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-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) 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 { if !file.IsComplete {
continue continue
} }
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil { if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
ctx.Status(http.StatusInternalServerError) ctx.Status(http.StatusInternalServerError)
return return false
} }
} }
if err := zipWriter.Close(); err != nil { if err := zipWriter.Close(); err != nil {
zipClosed = true zipClosed = true
ctx.Status(http.StatusInternalServerError) ctx.Status(http.StatusInternalServerError)
return return false
} }
zipClosed = true zipClosed = true
return true
}
if hasManifest && manifest.OneTimeDownload { func oneTimeDownloadLock(boxID string) *sync.Mutex {
boxstore.DeleteBox(boxID) lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
} else if hasManifest && app.config.RenewOnDownloadEnabled { return lock.(*sync.Mutex)
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
} }
func allFilesComplete(files []models.BoxFile) bool { func allFilesComplete(files []models.BoxFile) bool {
@@ -259,6 +299,10 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
ctx.String(http.StatusNotFound, "File not found") ctx.String(http.StatusNotFound, "File not found")
return return
} }
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
ctx.FileAttachment(path, filename) ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled { if hasManifest && app.config.RenewOnDownloadEnabled {
@@ -297,6 +341,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx)
boxID, err := boxstore.NewBoxID() boxID, err := boxstore.NewBoxID()
if err != nil { if err != nil {
@@ -332,6 +377,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx)
boxID := ctx.Param("id") boxID := ctx.Param("id")
fileID := ctx.Param("file_id") fileID := ctx.Param("file_id")
@@ -366,11 +412,12 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) { if !app.requireAPI(ctx) {
return return
} }
app.limitRequestBody(ctx)
boxID := ctx.Param("id") boxID := ctx.Param("id")
fileID := ctx.Param("file_id") fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) { if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
return return
} }
@@ -379,6 +426,14 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
return 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) file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
if err != nil { if err != nil {
@@ -393,6 +448,7 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx)
boxID := ctx.Param("id") boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) { if !boxstore.ValidBoxID(boxID) {
@@ -423,6 +479,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx)
form, err := ctx.MultipartForm() form, err := ctx.MultipartForm()
if err != nil { if err != nil {
@@ -580,14 +637,18 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
if err := app.validateFileSize(size); err != nil { if err := app.validateFileSize(size); err != nil {
return err return err
} }
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
manifest, err := boxstore.ReadManifest(boxID) manifest, err := boxstore.ReadManifest(boxID)
if err != nil { if err != nil {
return app.validateIncomingFile(boxID, size) 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) totalSize := int64(0)
found := false found := false
for _, file := range manifest.Files { for _, file := range manifest.Files {
@@ -624,6 +685,37 @@ func (app *App) validateBoxSize(size int64) error {
return nil 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 { func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if key == "" { if key == "" {

View 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)
}
}

View File

@@ -20,12 +20,13 @@
</header> </header>
<div class="win98-panel admin-panel"> <div class="win98-panel admin-panel">
<nav class="admin-nav"> <nav class="admin-nav">
<span>Signed in as {{ .CurrentUser }}</span> <span>Signed in as {{ .CurrentUser }}</span>
<span class="admin-spacer"></span> <span class="admin-spacer"></span>
<form action="/admin/logout" method="post"> <form action="/admin/logout" method="post">
<button class="win98-button" type="submit">Logout</button> <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
</form> <button class="win98-button" type="submit">Logout</button>
</nav> </form>
</nav>
<div class="admin-grid"> <div class="admin-grid">
<a class="win98-panel admin-link" href="/admin/boxes"><strong>Boxes</strong></a> <a class="win98-panel admin-link" href="/admin/boxes"><strong>Boxes</strong></a>
<a class="win98-panel admin-link" href="/admin/users"><strong>Users</strong></a> <a class="win98-panel admin-link" href="/admin/users"><strong>Users</strong></a>

View File

@@ -29,9 +29,10 @@
</nav> </nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}
<form class="admin-form" action="/admin/settings" method="post"> <form class="admin-form" action="/admin/settings" method="post">
<table class="admin-table"> <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>Setting</th> <th>Setting</th>

View File

@@ -29,11 +29,12 @@
</nav> </nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}
<form class="admin-form win98-panel" action="/admin/tags" method="post"> <form class="admin-form win98-panel" action="/admin/tags" method="post">
<label class="admin-form-row"> <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<span>Name</span> <label class="admin-form-row">
<input name="name" required> <span>Name</span>
<input name="name" required>
</label> </label>
<label class="admin-form-row"> <label class="admin-form-row">
<span>Description</span> <span>Description</span>

View File

@@ -29,11 +29,12 @@
</nav> </nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}
<form class="admin-form win98-panel" action="/admin/users" method="post"> <form class="admin-form win98-panel" action="/admin/users" method="post">
<label class="admin-form-row"> <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<span>Username</span> <label class="admin-form-row">
<input name="username" required> <span>Username</span>
<input name="username" required>
</label> </label>
<label class="admin-form-row"> <label class="admin-form-row">
<span>Email</span> <span>Email</span>
@@ -72,11 +73,12 @@
<td>{{ .Tags }}</td> <td>{{ .Tags }}</td>
<td>{{ .CreatedAt }}</td> <td>{{ .CreatedAt }}</td>
<td>{{ if .Disabled }}Disabled{{ else }}Active{{ end }}</td> <td>{{ if .Disabled }}Disabled{{ else }}Active{{ end }}</td>
<td> <td>
<form action="/admin/users" method="post"> <form action="/admin/users" method="post">
<input type="hidden" name="action" value="toggle_disabled"> <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="user_id" value="{{ .ID }}"> <input type="hidden" name="action" value="toggle_disabled">
<button class="win98-button" type="submit" {{ if .IsCurrent }}disabled{{ end }}>{{ if .Disabled }}Enable{{ else }}Disable{{ end }}</button> <input type="hidden" name="user_id" value="{{ .ID }}">
<button class="win98-button" type="submit" {{ if .IsCurrent }}disabled{{ end }}>{{ if .Disabled }}Enable{{ else }}Disable{{ end }}</button>
</form> </form>
</td> </td>
</tr> </tr>