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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user