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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
20
lib/helpers/paths_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user