diff --git a/datafiles/templates/Html/candidate-page.html.st b/datafiles/templates/Html/candidate-page.html.st index c7006d9c0..73737b847 100644 --- a/datafiles/templates/Html/candidate-page.html.st +++ b/datafiles/templates/Html/candidate-page.html.st @@ -95,10 +95,12 @@ $if(package.optional.hasHomePage)$ Home page - - - $package.optional.homepage$ - + + $if(package.optional.homepageIsSafeURI)$ + $package.optional.homepage$ + $else$ + $package.optional.homepage$ + $endif$ $endif$ @@ -106,10 +108,12 @@ $if(package.optional.hasBugTracker)$ Bug tracker - - - $package.optional.bugTracker$ - + + $if(package.optional.bugTrackerIsSafeURI)$ + $package.optional.bugTracker$ + $else$ + $package.optional.bugTracker$ + $endif$ $endif$ diff --git a/datafiles/templates/Html/package-page.html.st b/datafiles/templates/Html/package-page.html.st index 941c64a7d..4a42b83c8 100644 --- a/datafiles/templates/Html/package-page.html.st +++ b/datafiles/templates/Html/package-page.html.st @@ -204,7 +204,11 @@ Home page - $package.optional.homepage$ + $if(package.optional.homepageIsSafeURI)$ + $package.optional.homepage$ + $else$ + $package.optional.homepage$ + $endif$ $endif$ @@ -213,7 +217,11 @@ Bug tracker + $if(package.optional.bugTrackerIsSafeURI)$ $package.optional.bugTracker$ + $else$ + $package.optional.bugTracker$ + $endif$ $endif$ diff --git a/hackage-server.cabal b/hackage-server.cabal index 02ebb6aa6..ef5c984cd 100644 --- a/hackage-server.cabal +++ b/hackage-server.cabal @@ -229,6 +229,7 @@ library Distribution.Server.Framework.BlobStorage Distribution.Server.Framework.Cache Distribution.Server.Framework.Cron + Distribution.Server.Framework.CSRF Distribution.Server.Framework.Error Distribution.Server.Framework.Logging Distribution.Server.Framework.Feature diff --git a/src/Distribution/Server.hs b/src/Distribution/Server.hs index fe422e208..d1b944090 100644 --- a/src/Distribution/Server.hs +++ b/src/Distribution/Server.hs @@ -32,6 +32,7 @@ import qualified Distribution.Server.Framework.Auth as Auth import Distribution.Server.Framework.Templating (TemplatesMode(..)) import Distribution.Server.Framework.AuthTypes (PasswdPlain(..)) import Distribution.Server.Framework.HtmlFormWrapper (htmlFormWrapperHack) +import Distribution.Server.Framework.CSRF (csrfMiddleware) import Distribution.Server.Framework.Feature as Feature import qualified Distribution.Server.Features as Features @@ -301,10 +302,9 @@ initState server (admin, pass) = do impl :: Server -> ServerPart Response impl server = logExceptions $ runServerPartE $ - handleErrorResponse (serveErrorResponse errHandlers Nothing) $ - renderServerTree [] serverTree - `mplus` - fallbackNotFound + handleErrorResponse (serveErrorResponse errHandlers Nothing) $ do + csrfMiddleware + renderServerTree [] serverTree `mplus` fallbackNotFound where serverTree :: ServerTree (DynamicPath -> ServerPartE Response) serverTree = diff --git a/src/Distribution/Server/Features/Sitemap.hs b/src/Distribution/Server/Features/Sitemap.hs index 9227d70e7..5a4f865bc 100644 --- a/src/Distribution/Server/Features/Sitemap.hs +++ b/src/Distribution/Server/Features/Sitemap.hs @@ -35,7 +35,7 @@ import qualified Data.TarIndex as Tar import System.FilePath (takeExtension) data Sitemap - = Sitemap + = Sitemap { sitemapIndex :: XMLResponse , sitemaps :: [XMLResponse] } @@ -66,7 +66,7 @@ initSitemapFeature env@ServerEnv{ serverCacheDelay, return $ \coref@CoreFeature{..} docsCore@DocumentationFeature{..} - tagsf@TagsFeature{..} + tagsf@TagsFeature{..} tarf@TarIndexCacheFeature{..} -> do rec let (feature, updateSitemapCache) = @@ -178,7 +178,7 @@ generateSitemap :: URI -> (BlobId -> IO Tar.TarIndex) -> IO [ByteString] generateSitemap serverBaseURI pageBuildDate alltags pkgIndex docIndex cachedTarIndex = do - versionedDocSubEntries <- versionedDocSubEntriesIO + -- versionedDocSubEntries <- versionedDocSubEntriesIO let -- Combine and build sitemap allEntries = miscEntries ++ tagEntries @@ -186,7 +186,7 @@ generateSitemap serverBaseURI pageBuildDate alltags pkgIndex docIndex cachedTarI ++ nameVersEntries ++ baseDocEntries ++ versionedDocEntries - ++ versionedDocSubEntries + -- ++ versionedDocSubEntries pure $ renderSitemap serverBaseURI <$> chunksOf 50000 allEntries where -- Misc. pages @@ -270,6 +270,7 @@ generateSitemap serverBaseURI pageBuildDate alltags pkgIndex docIndex cachedTarI ] pageBuildDate Monthly 0.25 +{- -- Versioned doc pages in subdirectories -- versionedSubDocURIs :: [path :: String] -- e.g. ["http://myhackage.com/packages/mypackage-1.0.2/docs/Lib.html", ...] @@ -281,7 +282,7 @@ generateSitemap serverBaseURI pageBuildDate alltags pkgIndex docIndex cachedTarI pkgIndices <- traverse (\(pkg, blob) -> (pkg,) <$> cachedTarIndex blob) pkgs pure $ urlsToSitemapEntries [ prefixPkgURI ++ display (packageId pkg) ++ "/docs" ++ fp - | (pkg, tarIndex) <- pkgIndices + | (pkg, tarIndex) <- pkgIndices , Just tar <- [Tar.lookup tarIndex ""] , fp <- entryToPaths "/" tar , takeExtension fp == ".html" @@ -290,5 +291,6 @@ generateSitemap serverBaseURI pageBuildDate alltags pkgIndex docIndex cachedTarI entryToPaths :: FilePath -> Tar.TarIndexEntry -> [FilePath] entryToPaths _ (Tar.TarFileEntry _) = [] - entryToPaths base (Tar.TarDir content) = map ((base ) . fst) content ++ + entryToPaths base (Tar.TarDir content) = map ((base ) . fst) content ++ [ file | (folder, entry) <- content, file <- entryToPaths (base folder) entry ] +-} \ No newline at end of file diff --git a/src/Distribution/Server/Framework/Auth.hs b/src/Distribution/Server/Framework/Auth.hs index 87be49244..c979c53ff 100644 --- a/src/Distribution/Server/Framework/Auth.hs +++ b/src/Distribution/Server/Framework/Auth.hs @@ -28,6 +28,10 @@ module Distribution.Server.Framework.Auth ( -- ** Errors AuthError(..), authErrorResponse, + + -- ** Internal details + AuthMethod(..), + probeAttemptedAuthMethod, ) where import qualified Data.Text as T @@ -124,20 +128,32 @@ checkAuthenticated realm users ServerEnv { serverRequiredBaseHostHeader } = do Just (BasicAuth, ahdr) -> checkBasicAuth users realm ahdr Just (AuthToken, ahdr) -> checkTokenAuth users ahdr Nothing -> Left NoAuthError - getHeaderAuth :: Request -> Maybe (AuthType, BS.ByteString) - getHeaderAuth req = - case getHeader "authorization" req of - Just hdr - | BS.isPrefixOf (BS.pack "Digest ") hdr - -> Just (DigestAuth, BS.drop 7 hdr) - | BS.isPrefixOf (BS.pack "X-ApiKey ") hdr - -> Just (AuthToken, BS.drop 9 hdr) - | BS.isPrefixOf (BS.pack "Basic ") hdr - -> Just (BasicAuth, BS.drop 6 hdr) - _ -> Nothing - -data AuthType = BasicAuth | DigestAuth | AuthToken +-- | Authentication methods supported by hackage-server. +data AuthMethod + = -- | HTTP Basic authentication. + BasicAuth + | -- | HTTP Digest authentication. + DigestAuth + | -- | Authentication usinng an API token via the @X-ApiKey@ header. + AuthToken + +getHeaderAuth :: Request -> Maybe (AuthMethod, BS.ByteString) +getHeaderAuth req = + case getHeader "authorization" req of + Just hdr + | BS.isPrefixOf (BS.pack "Digest ") hdr + -> Just (DigestAuth, BS.drop 7 hdr) + | BS.isPrefixOf (BS.pack "X-ApiKey ") hdr + -> Just (AuthToken, BS.drop 9 hdr) + | BS.isPrefixOf (BS.pack "Basic ") hdr + -> Just (BasicAuth, BS.drop 6 hdr) + _ -> Nothing + +-- | Reads the request headers to determine which @AuthMethod@ the client has attempted to use, if +-- any. Note that this does not /validate/ the authentication credentials. +probeAttemptedAuthMethod :: Request -> Maybe AuthMethod +probeAttemptedAuthMethod = fmap fst . getHeaderAuth data PrivilegeCondition = InGroup Group.UserGroup | IsUserId UserId diff --git a/src/Distribution/Server/Framework/CSRF.hs b/src/Distribution/Server/Framework/CSRF.hs new file mode 100644 index 000000000..5b3d44821 --- /dev/null +++ b/src/Distribution/Server/Framework/CSRF.hs @@ -0,0 +1,55 @@ +-- | Middleware for performing CSRF checks. +module Distribution.Server.Framework.CSRF (csrfMiddleware) where + +import Control.Monad (unless) +import qualified Data.ByteString.Char8 as BS +import Distribution.Server.Framework.Auth (AuthMethod (AuthToken), probeAttemptedAuthMethod) +import Distribution.Server.Framework.Error +import Distribution.Server.Framework.HtmlFormWrapper (rqRealMethod) +import Happstack.Server + +isCsrfSafe :: Request -> Bool +isCsrfSafe req + | Just AuthToken <- probeAttemptedAuthMethod req = True + | rqRealMethod req `elem` safeMethods = True + | Just headerSecFetchSite <- getHeader "Sec-Fetch-Site" req = + headerSecFetchSite `elem` [BS.pack "same-origin", BS.pack "none"] + | Just userAgent <- getHeader "User-Agent" req, whitelistedUA userAgent = True + | otherwise = False + where + safeMethods = [GET, HEAD, OPTIONS] + -- TODO make this whitelist configurable + whitelistedUA ua = + any + (`BS.isPrefixOf` ua) + -- UA set by `cabal upload` and such + [ BS.pack "cabal-install/" + , -- Add some other common CLI tools here too? + BS.pack "curl/" + , -- referenced in this repository. Unclear whether strictly needed, but whitelisting just in case: + BS.pack "hackage-import/" + , BS.pack "hackage-mirror/" + , BS.pack "hackage-build/" + , BS.pack "hackage-server-testsuite/" + , -- default of HTTP library (used by test suite) + BS.pack "haskell-HTTP/" + , -- deprecated default of HTTP library + BS.pack "hs-http-" + ] + +-- | Middleware to check for CSRF safety. If the request fails the checks, then we throw a 403 error +-- with an appropriate message. +csrfMiddleware :: ServerPartE () +csrfMiddleware = do + req <- askRq + unless (isCsrfSafe req) $ do + throwError $ + ErrorResponse + 403 + [] + "Forbidden" + [ MText + "This request fails CSRF protection checks. For automated use cases consider \ + \switching to API tokens. For browsers, update to a more recent version of \ + \your browser which supports sec-fetch headers." + ] diff --git a/src/Distribution/Server/Framework/Cron.hs b/src/Distribution/Server/Framework/Cron.hs index 4a1b1d71a..5799b8d9e 100644 --- a/src/Distribution/Server/Framework/Cron.hs +++ b/src/Distribution/Server/Framework/Cron.hs @@ -80,7 +80,7 @@ removeExpiredJobs stateVar = nextJobTime :: UTCTime -> JobFrequency -> UTCTime nextJobTime now DailyJobFrequency = now { utctDay = addDays 1 (utctDay now), - utctDayTime = 0 + utctDayTime = secondsToDiffTime (60 * 90) -- 90 minutes after midnight } nextJobTime now WeeklyJobFrequency = now { utctDay = sundayAfter (utctDay now), @@ -155,4 +155,3 @@ threadDelayUntil target = do hour :: Num a => a hour = 60 * 60 * 1000000 - diff --git a/src/Distribution/Server/Pages/PackageFromTemplate.hs b/src/Distribution/Server/Pages/PackageFromTemplate.hs index edaa7a07b..82a7f877b 100644 --- a/src/Distribution/Server/Pages/PackageFromTemplate.hs +++ b/src/Distribution/Server/Pages/PackageFromTemplate.hs @@ -34,7 +34,7 @@ import Text.XHtml.Strict hiding (p, name, title, content) import qualified Text.XHtml.Strict as XHtml import Data.Maybe (maybeToList, fromMaybe, isJust) -import Data.List (intercalate, intersperse) +import Data.List (intercalate, intersperse, isPrefixOf) import System.FilePath.Posix ((), takeFileName, dropTrailingPathSeparator) import Data.Time.Format (defaultTimeLocale, formatTime) @@ -210,12 +210,16 @@ packagePageTemplate render [ templateVal "hasHomePage" (not . Short.null $ homepage desc) + , templateVal "homepageIsSafeURI" + (fromShortText (homepage desc) `hasScheme` [uriSchemeHttp, uriSchemeHttps]) , templateVal "homepage" (homepage desc) ] ++ [ templateVal "hasBugTracker" (not . Short.null $ bugReports desc) + , templateVal "bugTrackerIsSafeURI" + (fromShortText (bugReports desc) `hasScheme` [uriSchemeHttp, uriSchemeHttps, uriSchemeMailto]) , templateVal "bugTracker" (bugReports desc) ] ++ @@ -376,6 +380,16 @@ renderVersion (PackageIdentifier pname pversion) allVersions info = Nothing -> noHtml Just str -> " (" +++ (anchor ! [href str] << "info") +++ ")" +uriSchemeHttp, uriSchemeHttps, uriSchemeMailto :: String +uriSchemeHttp = "http:" +uriSchemeHttps = "https:" +uriSchemeMailto = "mailto:" + +-- | Check if string starts with one of the given URI schemes. +-- Schemes are given __with trailing @:@__. +hasScheme :: String -> [String] -> Bool +hasScheme s = any (`isPrefixOf` s) + sourceRepositoryToHtml :: SourceRepo -> Html sourceRepositoryToHtml sr = toHtml (display (repoKind sr) ++ ": ") @@ -384,21 +398,20 @@ sourceRepositoryToHtml sr | (Just url, Nothing, Nothing) <- (repoLocation sr, repoModule sr, repoBranch sr) -> concatHtml [toHtml "darcs get ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoTag sr of Just tag' -> toHtml (" --tag " ++ tag') Nothing -> noHtml, case repoSubdir sr of Just sd -> toHtml " (" - +++ (anchor ! [href (url sd)] - << toHtml sd) + +++ anchorOrBare (url sd) sd +++ toHtml ")" Nothing -> noHtml] Just (KnownRepoType Git) | (Just url, Nothing) <- (repoLocation sr, repoModule sr) -> concatHtml [toHtml "git clone ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoBranch sr of Just branch -> toHtml (" -b " ++ branch) Nothing -> noHtml, @@ -412,7 +425,7 @@ sourceRepositoryToHtml sr | (Just url, Nothing, Nothing, Nothing) <- (repoLocation sr, repoModule sr, repoBranch sr, repoTag sr) -> concatHtml [toHtml "svn checkout ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoSubdir sr of Just sd -> toHtml ("(" ++ sd ++ ")") Nothing -> noHtml] @@ -420,7 +433,7 @@ sourceRepositoryToHtml sr | (Just url, Just m, Nothing, Nothing) <- (repoLocation sr, repoModule sr, repoBranch sr, repoTag sr) -> concatHtml [toHtml "cvs -d ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, toHtml (" " ++ m), case repoSubdir sr of Just sd -> toHtml ("(" ++ sd ++ ")") @@ -429,7 +442,7 @@ sourceRepositoryToHtml sr | (Just url, Nothing) <- (repoLocation sr, repoModule sr) -> concatHtml [toHtml "hg clone ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoBranch sr of Just branch -> toHtml (" -b " ++ branch) Nothing -> noHtml, @@ -443,7 +456,7 @@ sourceRepositoryToHtml sr | (Just url, Nothing, Nothing) <- (repoLocation sr, repoModule sr, repoBranch sr) -> concatHtml [toHtml "bzr branch ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoTag sr of Just tag' -> toHtml (" -r " ++ tag') Nothing -> noHtml, @@ -454,7 +467,7 @@ sourceRepositoryToHtml sr | Just url <- repoLocation sr -> concatHtml [toHtml "fossil clone ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, toHtml " ", toHtml (takeFileName (dropTrailingPathSeparator url) ++ ".fossil") ] @@ -462,7 +475,7 @@ sourceRepositoryToHtml sr | (Just url, Nothing, Nothing) <- (repoLocation sr, repoModule sr, repoTag sr) -> concatHtml [toHtml "pijul clone ", - anchor ! [href url] << toHtml url, + anchorOrBare url url, case repoBranch sr of Just branch -> toHtml (" --from-branch " ++ branch) Nothing -> noHtml, @@ -477,12 +490,18 @@ sourceRepositoryToHtml sr let url = fromMaybe "" $ repoLocation sr showRepoType (OtherRepoType rt) = rt showRepoType x = show x - in concatHtml $ [anchor ! [href url] << toHtml url] + in concatHtml $ [anchorOrBare url url] ++ fmap (\r -> toHtml $ ", repo type " ++ showRepoType r) (maybeToList $ repoType sr) ++ fmap (\x -> toHtml $ ", module " ++ x) (maybeToList $ repoModule sr) ++ fmap (\x -> toHtml $ ", branch " ++ x) (maybeToList $ repoBranch sr) ++ fmap (\x -> toHtml $ ", tag " ++ x) (maybeToList $ repoTag sr) ++ fmap (\x -> toHtml $ ", subdir " ++ x) (maybeToList $ repoSubdir sr) + where + -- only make hyperlink if URI matches acceptable schemes + anchorOrBare url text + | url `hasScheme` schemes = anchor ! [href url] << toHtml text + | otherwise = toHtml text + schemes = [uriSchemeHttp, uriSchemeHttps] -- | Handle how version links are displayed.