Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions fileutils/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,62 @@ package fileutils

import (
"os"
"path"
"path/filepath"
"testing"

"github.com/filebrowser/filebrowser/v2/files"
"github.com/spf13/afero"
)

// failingOpenFs wraps an afero.Fs and makes Open fail for one specific path,
// while every other operation (including Stat) is delegated unchanged. It
// simulates a directory that can be stat-ed but not opened/read — for example
// an unreadable sub-directory, or one whose permissions changed or that was
// removed after its parent was listed (a TOCTOU race) — encountered during a
// recursive copy.
type failingOpenFs struct {
afero.Fs
failOpen string
}

func (f *failingOpenFs) Open(name string) (afero.File, error) {
if path.Clean(name) == path.Clean(f.failOpen) {
return nil, os.ErrPermission
}
return f.Fs.Open(name)
}

// CopyDir is documented to keep going when it hits an error and to report the
// error afterwards. A sub-directory that cannot be opened must therefore yield
// an error (and leave the other, readable entries copied) rather than
// panicking on a nil directory handle.
func TestCopyDirUnreadableSubdirReturnsError(t *testing.T) {
mem := afero.NewMemMapFs()
if err := mem.MkdirAll("/srcdir/sub", 0o755); err != nil {
t.Fatal(err)
}
if err := afero.WriteFile(mem, "/srcdir/ok.txt", []byte("readable"), 0o644); err != nil {
t.Fatal(err)
}

afs := &failingOpenFs{Fs: mem, failOpen: "/srcdir/sub"}

err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755)
if err == nil {
t.Fatal("expected an error when a sub-directory cannot be opened")
}

// The readable sibling must still have been copied (continue-on-error).
data, readErr := afero.ReadFile(afs, "/dstdir/ok.txt")
if readErr != nil {
t.Fatalf("readable sibling was not copied: %v", readErr)
}
if string(data) != "readable" {
t.Fatalf("unexpected copied content: %q", string(data))
}
}

// Copying an in-scope directory that contains a symlink whose target escapes
// the user's scope must not dereference that symlink into the destination.
// Otherwise a scoped user could exfiltrate out-of-scope file content via the
Expand Down
7 changes: 6 additions & 1 deletion fileutils/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) e
return err
}

dir, _ := afs.Open(source)
dir, err := afs.Open(source)
if err != nil {
return err
}
defer dir.Close()

obs, err := dir.Readdir(-1)
if err != nil {
return err
Expand Down
50 changes: 25 additions & 25 deletions frontend/src/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,12 @@
"passwordsDontMatch": "Les mots de passe ne concordent pas",
"signup": "S'inscrire",
"submit": "Se connecter",
"username": "Utilisateur",
"usernameTaken": "Le nom d'utilisateur est déjà pris",
"username": "Identifiant",
"usernameTaken": "L'identifiant est déjà pris",
"wrongCredentials": "Identifiants incorrects !",
"passwordTooShort": "Le mot de passe doit contenir au moins {min} caractères",
"logout_reasons": {
"inactivity": "Vous avez été déconnecté(e) en raison d'une inactivité prolongée."
"inactivity": "Vous avez été déconnecté'e en raison d'une inactivité prolongée."
}
},
"permanent": "Permanent",
Expand Down Expand Up @@ -194,12 +194,12 @@
"settings": {
"aceEditorTheme": "Éditeur de Thème Ace",
"admin": "Admin",
"administrator": "Administrateur",
"administrator": "Administrateur'ice",
"allowCommands": "Exécuter des commandes",
"allowEdit": "Éditer, renommer et supprimer des fichiers ou des dossiers",
"allowNew": "Créer de nouveaux fichiers et dossiers",
"allowPublish": "Publier de nouveaux posts et pages",
"allowSignup": "Autoriser les utilisateurs à s'inscrire",
"allowSignup": "Autoriser les utilisateur'ices à s'inscrire",
"hideLoginButton": "Cacher le bouton d’identification sur les pages publiques",
"avoidChanges": "(Laisser vide pour conserver l'actuel)",
"branding": "Image de marque",
Expand All @@ -209,34 +209,34 @@
"commandRunner": "Exécuteur de commandes",
"commandRunnerHelp": "Ici, vous pouvez définir les commandes qui seront exécutées lors des événements nommés précédemments. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez lire la {2}.",
"commandsUpdated": "Commandes mises à jour !",
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur",
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur'ice",
"minimumPasswordLength": "Taille minimale du mot de passe",
"tusUploads": "Uploads segmentés",
"tusUploadsHelp": "File Browser prend en charge les uploads segmentés afin de permettre une gestion efficace, fiable et reprenable sur des réseaux instables.",
"tusUploadsChunkSize": "Taille maximale autorisée par segment (les uploads directs seront utilisés pour les fichiers plus petits). Vous pouvez entrer un entier en octets ou une chaîne telle que 10MB, 1GB, etc.",
"tusUploadsRetryCount": "Nombre de tentatives en cas d'échec d'un segment.",
"userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateurs",
"userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateur'ices",
"userScopeGenerationPlaceholder": "Le périmètre sera généré automatiquement",
"createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur",
"createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur'ice",
"customStylesheet": "Feuille de style personnalisée",
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateurs.",
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateur'ices.",
"disableExternalLinks": "Désactiver les liens externes (sauf la documentation)",
"disableUsedDiskPercentage": "Désactiver le graphique de pourcentage d'utilisation du disque",
"documentation": "documentation",
"examples": "Exemples",
"executeOnShell": "Exécuter dans le shell",
"executeOnShellDescription": "Par défaut, File Browser exécute les commandes en appelant directement leurs binaires. Si vous voulez les exécuter sur un shell à la place (comme Bash ou PowerShell), vous pouvez le définir ici avec les arguments et les drapeaux requis. S'il est défini, la commande que vous exécutez sera ajoutée en tant qu'argument. Cela s'applique à la fois aux commandes utilisateur et aux crochets d'événements.",
"globalRules": "Il s'agit d'un ensemble global de règles d'autorisation et d'interdiction. Elles s'appliquent à tous les utilisateurs. Vous pouvez définir des règles spécifiques sur les paramètres de chaque utilisateur pour remplacer celles-ci.",
"executeOnShellDescription": "Par défaut, File Browser exécute les commandes en appelant directement leurs binaires. Si vous voulez les exécuter sur un shell à la place (comme Bash ou PowerShell), vous pouvez le définir ici avec les arguments et les drapeaux requis. S'il est défini, la commande que vous exécutez sera ajoutée en tant qu'argument. Cela s'applique à la fois aux commandes utilisateur'ice et aux crochets d'événements.",
"globalRules": "Il s'agit d'un ensemble global de règles d'autorisation et d'interdiction. Elles s'appliquent à tous les utilisateur'ices. Vous pouvez définir des règles spécifiques sur les paramètres de chaque utilisateur'ice pour remplacer celles-ci.",
"globalSettings": "Paramètres globaux",
"hideDotfiles": "Cacher les fichiers de configuration commançant par un point",
"insertPath": "Insérer le chemin",
"insertRegex": "Insérer une expression régulière",
"instanceName": "Nom de l'instance",
"language": "Langue",
"lockPassword": "Empêcher l'utilisateur de changer son mot de passe",
"lockPassword": "Empêcher l'utilisateur'ice de changer son mot de passe",
"newPassword": "Votre nouveau mot de passe",
"newPasswordConfirm": "Confirmation du nouveau mot de passe",
"newUser": "Nouvel utilisateur",
"newUser": "Nouvel'le utilisateur'ice",
"password": "Mot de passe",
"passwordUpdated": "Mot de passe mis à jour !",
"path": "Chemin",
Expand All @@ -250,14 +250,14 @@
"share": "Partager des fichiers (autorisation de téléchargement requise)"
},
"permissions": "Permissions",
"permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n",
"permissionsHelp": "Vous pouvez définir l'utilisateur'ice comme étant un'e administrateur'ice ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur'ice\", toutes les autres options seront automatiquement activées. La gestion des utilisateur'ices est un privilège que seul l'administrateur'ice possède.\n",
"profileSettings": "Paramètres du profil",
"redirectAfterCopyMove": "Rediriger vers la destination après une copie/déplacement",
"ruleExample1": "Bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers.\n",
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur",
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur'ice",
"rules": "Règles",
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur.\n",
"scope": "Portée du dossier utilisateur",
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet'te utilisateur'ice. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur'ice. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur'ice.\n",
"scope": "Portée du dossier utilisateur'ice",
"setDateFormat": "Définir le format de la date",
"settingsUpdated": "Les paramètres ont été mis à jour !",
"shareDuration": "Durée du partage",
Expand All @@ -270,16 +270,16 @@
"light": "Clair",
"title": "Thème"
},
"user": "Utilisateur",
"user": "Utilisateur'ice",
"userCommands": "Commandes",
"userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur. Exemple :\n",
"userCreated": "Utilisateur créé !",
"userDefaults": "Paramètres par défaut de l'utilisateur",
"userDeleted": "Utilisateur supprimé !",
"userManagement": "Gestion des utilisateurs",
"userUpdated": "Utilisateur mis à jour !",
"username": "Nom d'utilisateur",
"users": "Utilisateurs",
"userCreated": "Utilisateur'ice créé !",
"userDefaults": "Paramètres par défaut de l'utilisateur'ice",
"userDeleted": "Utilisateur'ice supprimé !",
"userManagement": "Gestion des utilisateur'ices",
"userUpdated": "Utilisateur'ice mis à jour !",
"username": "Nom d'utilisateur'ice",
"users": "Utilisateur'ices",
"currentPassword": "Mot de Passe Actuel"
},
"sidebar": {
Expand Down
4 changes: 3 additions & 1 deletion http/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ func resourcePostHandler(fileCache FileCache) handleFunc {

// Directories creation on POST.
if strings.HasSuffix(r.URL.Path, "/") {
err := d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
err := d.RunHook(func() error {
return d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
}, "upload", r.URL.Path, "", d.user)
return errToStatus(err), err
}

Expand Down
35 changes: 35 additions & 0 deletions http/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,38 @@ func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) {
t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr)
}
}

func TestResourcePostRunsUploadHooksForDirectories(t *testing.T) {
root := t.TempDir()
userScope := filepath.Join(root, "user")
if err := os.MkdirAll(userScope, 0o755); err != nil {
t.Fatal(err)
}

key := []byte("test-signing-key")
perm := users.Permissions{Create: true}
st := scopedUserStorage(t, userScope, perm, key)
if err := st.Settings.Save(&settings.Settings{
Key: key,
Commands: map[string][]string{
"after_upload": {"filebrowser-hook-command-that-does-not-exist"},
},
}); err != nil {
t.Fatal(err)
}

req, _ := http.NewRequest(http.MethodPost, "/created/", http.NoBody)
req.Header.Set("X-Auth", signToken(t, perm, key))
rec := httptest.NewRecorder()
handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{EnableExec: true}).ServeHTTP(rec, req)

// A missing after_upload command makes the request fail only if the hook ran.
// It avoids a platform-specific helper executable while still exercising the
// same path the web UI uses for directory uploads.
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected directory upload hook failure to return 500, got %d body=%q", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(userScope, "created")); err != nil {
t.Fatalf("expected directory to be created before its after hook, got %v", err)
}
}
Loading