core: prevent remote controller from writing files outside files folder (#7352)

This commit is contained in:
spaced4ndy
2026-08-07 13:26:48 +00:00
committed by GitHub
parent b832d7c8f7
commit e04e080a3d
4 changed files with 56 additions and 11 deletions
+35 -4
View File
@@ -11,22 +11,25 @@ import ChatTests.DBUtils
import ChatTests.Utils
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except (runExceptT)
import qualified Data.Aeson as J
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.List (find, isPrefixOf)
import qualified Data.Map.Strict as M
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber)
import Simplex.Chat.Files (safeFileNameStr)
import Simplex.Chat.Library.Commands (parseChatCommand)
import qualified Simplex.Chat.Controller as Controller
import Simplex.Chat.Mobile.File
import Simplex.Chat.Remote (remoteFilesFolder)
import Simplex.Chat.Remote (remoteFilesFolder, validRemoteFileName)
import Simplex.Chat.Remote.Protocol (remoteStoreFile)
import Simplex.Chat.Remote.Types
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util
import Simplex.RemoteControl.Types (RCCtrlAddress (..))
import System.FilePath ((</>))
import System.FilePath (takeFileName, (</>))
import Test.Hspec hiding (it)
import UnliftIO
import UnliftIO.Concurrent
@@ -40,10 +43,26 @@ remoteTests = describe "Remote" $ do
`shouldSatisfy` \case
Right (StartRemoteHost Nothing (Just (RCCtrlAddress _ "Ethernet 2")) (Just 12345)) -> True
_ -> False
describe "stored file name" $ do
it "rejects names with directory components" $ \_ ->
filter validRemoteFileName ["../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "", ".", ".."]
`shouldBe` []
it "accepts bare file names" $ \_ ->
filter (not . validRemoteFileName) ["test.pdf", "test_1.pdf", ".hidden", "a b.tar.gz"]
`shouldBe` []
it "sanitizes any name to a real file name" $ \_ ->
filter (not . sanitized) fileNames `shouldBe` []
it "sanitizes to a name with no directory components" $ \_ ->
filter (not . bareName) fileNames `shouldBe` []
xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests
xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests
xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests
describe "With compression" $ aroundWith (. ((True, True),)) runRemoteTests
where
fileNames :: [FilePath]
fileNames = ["", ".", "..", "...", "../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "test.pdf", ".hidden", "a b.tar.gz"]
sanitized n = let n' = safeFileNameStr n in n' /= "" && n' /= "." && n' /= ".."
bareName n = let n' = safeFileNameStr n in n' == takeFileName n'
runRemoteTests :: SpecWith ((Bool, Bool), TestParams)
runRemoteTests = do
@@ -243,8 +262,9 @@ remoteStoreFileTest =
contactBob desktop bob
rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop)
desktopHostStore <- case M.lookup (RHId 1) rhs of
Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles </> storePath </> remoteFilesFolder
(rhClient, desktopHostStore) <- case M.lookup (RHId 1) rhs of
Just (_, RHSessionConnected {rhClient, storePath}) ->
pure (rhClient, desktopHostFiles </> storePath </> remoteFilesFolder)
_ -> fail "Host session 1 should be started"
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
desktop <## "file test.pdf stored on remote host 1"
@@ -261,6 +281,17 @@ remoteStoreFileTest =
chatReadFile (mobileFiles </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
chatReadFile (desktopHostStore </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
-- the host rejects a traversal name before draining the attachment; only calling the protocol
-- directly can put such a name on the wire, as /store remote file sanitizes it controller-side
runExceptT (remoteStoreFile rhClient "tests/fixtures/test.pdf" "../x") >>= \case
Left (RPEInvalidBody _) -> pure ()
r -> fail $ "expected RPEInvalidBody, got " <> show r
doesFileExist "./tests/tmp/x" `shouldReturn` False
-- the undrained attachment did not break the session
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
desktop <## "file test_3.pdf stored on remote host 1"
B.readFile (mobileFiles </> "test_3.pdf") `shouldReturn` src
removeFile (desktopHostStore </> "test_1.pdf")
removeFile (desktopHostStore </> "test_2.pdf")