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