Skip to content

Commit a454a13

Browse files
committed
Add required-base-host-header configuration option
This facilitates hosting behind a reverse proxy.
1 parent 5a0dc33 commit a454a13

File tree

4 files changed

+73
-65
lines changed

4 files changed

+73
-65
lines changed

exes/Main.hs

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import System.Directory
3838
import System.FilePath
3939
( (</>), (<.>) )
4040
import Network.URI
41-
( URI(..), parseAbsoluteURI )
41+
( URI(..), URIAuth(..), parseAbsoluteURI )
4242
import Distribution.Simple.Command
4343
import Distribution.Simple.Setup
4444
( Flag(..), fromFlag, fromFlagOrDefault, flagToList, flagToMaybe )
@@ -198,6 +198,7 @@ data RunFlags = RunFlags {
198198
flagRunIP :: Flag String,
199199
flagRunHostURI :: Flag String,
200200
flagRunUserContentURI :: Flag String,
201+
flagRunRequiredBaseHostHeader :: Flag String,
201202
flagRunStateDir :: Flag FilePath,
202203
flagRunStaticDir :: Flag FilePath,
203204
flagRunTmpDir :: Flag FilePath,
@@ -217,6 +218,7 @@ defaultRunFlags = RunFlags {
217218
flagRunIP = NoFlag,
218219
flagRunHostURI = NoFlag,
219220
flagRunUserContentURI = NoFlag,
221+
flagRunRequiredBaseHostHeader = NoFlag,
220222
flagRunStateDir = NoFlag,
221223
flagRunStaticDir = NoFlag,
222224
flagRunTmpDir = NoFlag,
@@ -266,10 +268,14 @@ runCommand =
266268
"Server's public base URI (defaults to machine name)"
267269
flagRunHostURI (\v flags -> flags { flagRunHostURI = v })
268270
(reqArgFlag "NAME")
269-
, option [] ["user-content-host"]
270-
"Server's public user content host name (for untrusted content, defeating XSS style attacks)"
271+
, option [] ["user-content-uri"]
272+
"Server's public user content base URI (for untrusted content, defeating XSS style attacks)"
271273
flagRunUserContentURI (\v flags -> flags { flagRunUserContentURI = v })
272274
(reqArgFlag "NAME")
275+
, option [] ["required-base-host-header"]
276+
"Required host header value for incoming requests (potentially internal, e.g. if behind reverse proxy). Base means that it is _not_ for the user-content domain."
277+
flagRunRequiredBaseHostHeader (\v flags -> flags { flagRunRequiredBaseHostHeader = v })
278+
(reqArgFlag "NAME")
273279
, optionStateDir
274280
flagRunStateDir (\v flags -> flags { flagRunStateDir = v })
275281
, optionStaticDir
@@ -313,7 +319,8 @@ runAction opts = do
313319
port <- checkPortOpt defaults (flagToMaybe (flagRunPort opts))
314320
ip <- checkIPOpt defaults (flagToMaybe (flagRunIP opts))
315321
hosturi <- checkHostURI defaults (flagToMaybe (flagRunHostURI opts)) port
316-
usercontenthost <- checkUserContentHost defaults (flagToMaybe (flagRunUserContentURI opts))
322+
usercontenturi <- checkUserContentURI defaults (flagToMaybe (flagRunUserContentURI opts))
323+
requiredbasehostheader <- checkRequiredBaseHostHeader defaults (flagToMaybe (flagRunRequiredBaseHostHeader opts))
317324
cacheDelay <- checkCacheDelay defaults (flagToMaybe (flagRunCacheDelay opts))
318325
let stateDir = fromFlagOrDefault (confStateDir defaults) (flagRunStateDir opts)
319326
staticDir = fromFlagOrDefault (confStaticDir defaults) (flagRunStaticDir opts)
@@ -324,7 +331,8 @@ runAction opts = do
324331
}
325332
config = defaults {
326333
confHostUri = hosturi,
327-
confUserContentHost = usercontenthost,
334+
confUserContentUri = usercontenturi,
335+
confRequiredBaseHostHeader = requiredbasehostheader,
328336
confListenOn = listenOn,
329337
confStateDir = stateDir,
330338
confStaticDir = staticDir,
@@ -378,23 +386,37 @@ runAction opts = do
378386
-> return n
379387
_ -> fail $ "bad port number " ++ show str
380388

381-
checkHostURI defaults Nothing _ = fail "You must provide the --base-uri= flag"
382-
checkHostURI _ (Just str) _ = case parseAbsoluteURI str of
389+
checkHostURI defaults Nothing port = do
390+
let guessURI = confHostUri defaults
391+
Just authority = uriAuthority guessURI
392+
portStr | port == 80 = ""
393+
| otherwise = ':' : show port
394+
guessURI' = guessURI { uriAuthority = Just authority { uriPort = portStr } }
395+
lognotice verbosity $ "Guessing public URI as " ++ show guessURI'
396+
++ "\n(you can override with the --base-uri= flag)"
397+
return guessURI'
398+
399+
checkHostURI _ (Just str) _ = validateURI str
400+
401+
validateURI str = case parseAbsoluteURI str of
383402
Nothing -> fail $ "Cannot parse as a URI: " ++ str ++ "\n"
384403
++ "Make sure you include the http:// part"
385404
Just uri
386405
| uriScheme uri `notElem` ["http:", "https:"] ->
387406
fail $ "Sorry, the server assumes it will be served (or proxied) "
388407
++ " via http or https, so cannot use uri scheme " ++ uriScheme uri
389408
| isNothing (uriAuthority uri) ->
390-
fail "The base-uri has to include the full host name"
409+
fail "The base-uri and the user-content-uri have to include full host names"
391410
| uriPath uri `notElem` ["", "/"] ->
392-
fail $ "Sorry, the server assumes the base-uri to be at the root of "
393-
++ " the domain, so cannot use " ++ uriPath uri
411+
fail $ "Sorry, the server assumes base-uri and user-content-uri to be at the root of "
412+
++ " their domains, so cannot use " ++ uriPath uri
394413
| otherwise -> return uri { uriPath = "" }
395414

396-
checkUserContentHost _ Nothing = fail "You must provide the --user-content-host= flag"
397-
checkUserContentHost _ (Just str) = pure str
415+
checkUserContentURI _ Nothing = fail "You must provide the --user-content-uri= flag"
416+
checkUserContentURI _ (Just str) = validateURI str
417+
418+
checkRequiredBaseHostHeader _ Nothing = fail "You must provide the --required-base-host-header= flag. It's typically the host part of the base-uri."
419+
checkRequiredBaseHostHeader _ (Just str) = pure str
398420

399421
checkIPOpt defaults Nothing = return (loIP (confListenOn defaults))
400422
checkIPOpt _ (Just str) =

src/Distribution/Server.hs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ data ListenOn = ListenOn {
6868
data ServerConfig = ServerConfig {
6969
confVerbosity :: Verbosity,
7070
confHostUri :: URI,
71-
confUserContentHost :: String,
71+
confUserContentUri :: URI,
72+
confRequiredBaseHostHeader :: String,
7273
confListenOn :: ListenOn,
7374
confStateDir :: FilePath,
7475
confStaticDir :: FilePath,
@@ -97,7 +98,8 @@ defaultServerConfig = do
9798
uriScheme = "http:",
9899
uriAuthority = Just (URIAuth "" hostName (':' : show portnum))
99100
},
100-
confUserContentHost = "",
101+
confUserContentUri = nullURI, -- This is a required argument, so the default doesn't matter
102+
confRequiredBaseHostHeader = "", -- This is a required argument, so the default doesn't matter
101103
confListenOn = ListenOn {
102104
loPortNum = 8080,
103105
loIP = "127.0.0.1"
@@ -124,7 +126,7 @@ hasSavedState :: ServerConfig -> IO Bool
124126
hasSavedState = doesDirectoryExist . confDbStateDir
125127

126128
mkServerEnv :: ServerConfig -> IO ServerEnv
127-
mkServerEnv config@(ServerConfig verbosity hostURI userContentHost _
129+
mkServerEnv config@(ServerConfig verbosity hostURI userContentURI requiredBaseHostHeader _
128130
stateDir _ tmpDir
129131
cacheDelay liveTemplates) = do
130132
createDirectoryIfMissing False stateDir
@@ -149,7 +151,8 @@ mkServerEnv config@(ServerConfig verbosity hostURI userContentHost _
149151
serverTmpDir = tmpDir,
150152
serverCacheDelay = cacheDelay * 1000000, --microseconds
151153
serverBaseURI = hostURI,
152-
serverUserContentHost = userContentHost,
154+
serverUserContentBaseURI = userContentURI,
155+
serverRequiredBaseHostHeader = requiredBaseHostHeader,
153156
serverVerbosity = verbosity
154157
}
155158
return env

src/Distribution/Server/Framework/Auth.hs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ module Distribution.Server.Framework.Auth (
3030
authErrorResponse,
3131
) where
3232

33+
import qualified Data.Text as T
3334
import Distribution.Server.Users.Types (UserId, UserName(..), UserAuth(..), UserInfo)
3435
import qualified Distribution.Server.Users.Types as Users
3536
import qualified Distribution.Server.Users.Users as Users
@@ -39,7 +40,7 @@ import Distribution.Server.Framework.AuthCrypt
3940
import Distribution.Server.Framework.AuthTypes
4041
import Distribution.Server.Framework.Error
4142
import Distribution.Server.Framework.HtmlFormWrapper (rqRealMethod)
42-
import Distribution.Server.Framework.ServerEnv (ServerEnv, isRegularHost)
43+
import Distribution.Server.Framework.ServerEnv (ServerEnv(ServerEnv, serverRequiredBaseHostHeader), getHost)
4344

4445
import Happstack.Server
4546

@@ -103,10 +104,19 @@ guardAuthenticated realm users env = do
103104
Right info -> return info
104105

105106
checkAuthenticated :: ServerMonad m => RealmName -> Users.Users -> ServerEnv -> m (Either AuthError (UserId, UserInfo))
106-
checkAuthenticated realm users env = do
107-
mbHostMismatch <- isRegularHost env
108-
case mbHostMismatch of
109-
Just (actualHost, oughtToBeHost) -> pure (Left BadHost { actualHost , oughtToBeHost })
107+
checkAuthenticated realm users ServerEnv { serverRequiredBaseHostHeader } = do
108+
mbHost <- getHost
109+
case mbHost of
110+
Just hostHeaderValue ->
111+
if hostHeaderValue /= T.encodeUtf8 (T.pack serverRequiredBaseHostHeader)
112+
then pure $ Left BadHost
113+
{ actualHost=Just hostHeaderValue
114+
, oughtToBeHost=serverRequiredBaseHostHeader
115+
}
116+
else pure $ Left BadHost
117+
{ actualHost=Nothing
118+
, oughtToBeHost=serverRequiredBaseHostHeader
119+
}
110120
Nothing -> do
111121
req <- askRq
112122
return $ case getHeaderAuth req of
@@ -430,7 +440,7 @@ data AuthError = NoAuthError
430440
| UserStatusError UserId UserInfo
431441
| PasswordMismatchError UserId UserInfo
432442
| BadApiKeyError
433-
| BadHost { actualHost :: BS.ByteString, oughtToBeHost :: String }
443+
| BadHost { actualHost :: Maybe BS.ByteString, oughtToBeHost :: String }
434444
deriving Show
435445

436446
authErrorResponse :: MonadIO m => RealmName -> AuthError -> m ErrorResponse

src/Distribution/Server/Framework/ServerEnv.hs

Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import qualified Data.ByteString as BS
1111
import qualified Data.Text as T
1212
import qualified Network.URI as URI
1313
import Data.List (find)
14-
import Data.Text.Encoding (decodeASCII', encodeUtf8)
14+
import Data.Text.Encoding (encodeUtf8)
1515
import Happstack.Server (ServerMonad(askRq))
1616
import Happstack.Server.Response (seeOther, toResponse)
1717
import Happstack.Server.Types (HeaderPair(..), Response, rqHeaders, rqQuery, rqUri)
@@ -58,7 +58,9 @@ data ServerEnv = ServerEnv {
5858
-- current server (e.g. as required in RSS feeds).
5959
serverBaseURI :: URI.URI,
6060

61-
serverUserContentHost :: String,
61+
serverUserContentBaseURI :: URI.URI,
62+
-- | This might be an internal host name, used internally behind a load balancer
63+
serverRequiredBaseHostHeader :: String,
6264

6365
-- | A tunable parameter for cache policy. Setting this parameter high
6466
-- during bulk imports can very significantly improve performance. During
@@ -74,55 +76,26 @@ data ServerEnv = ServerEnv {
7476
serverVerbosity :: Verbosity
7577
}
7678

77-
getHostAndBaseAuth :: ServerMonad m => ServerEnv -> m (Maybe (BS.ByteString, URI.URIAuth))
78-
getHostAndBaseAuth ServerEnv {serverBaseURI} = do
79+
getHost :: ServerMonad m => m (Maybe BS.ByteString)
80+
getHost = do
7981
rq <- askRq
80-
let
81-
mbHost :: Maybe BS.ByteString
82-
mbHost =
83-
case find ((== encodeUtf8 (T.pack "host")) . hName) $ rqHeaders rq of
84-
Just hostHeaderPair | [oneValue] <- hValue hostHeaderPair ->
85-
-- If there is a colon in the host header, remove it.
86-
-- We require the regName of user-content and base to differ.
87-
-- 58 is ASCII for colon
88-
let (beforeColon, _colonAndAfter) = BS.break (== 58) oneValue
89-
in Just beforeColon
90-
_ -> Nothing
91-
mbBaseAuth = URI.uriAuthority serverBaseURI
92-
case (,) <$> mbHost <*> mbBaseAuth of
93-
Nothing -> pure Nothing
94-
Just (hostHeaderValue, baseAuth) -> pure $ Just (hostHeaderValue, baseAuth)
82+
pure $
83+
case find ((== encodeUtf8 (T.pack "host")) . hName) $ rqHeaders rq of
84+
Just hostHeaderPair | [oneValue] <- hValue hostHeaderPair -> Just oneValue
85+
_ -> Nothing
9586

9687
requireUserContent :: ServerEnv -> Response -> ServerPartE Response
97-
requireUserContent env@ServerEnv {serverBaseURI, serverUserContentHost} action = do
98-
Just (hostHeaderValue, baseAuth) <- getHostAndBaseAuth env
88+
requireUserContent ServerEnv {serverUserContentBaseURI, serverRequiredBaseHostHeader} action = do
89+
Just hostHeaderValue <- getHost
9990
rq <- askRq
100-
if hostHeaderValue == encodeUtf8 (T.pack $ URI.uriRegName baseAuth)
91+
if hostHeaderValue == encodeUtf8 (T.pack serverRequiredBaseHostHeader)
10192
then
10293
let
103-
uri = URI.URI
104-
{ URI.uriScheme = URI.uriScheme serverBaseURI
105-
, URI.uriAuthority = Just URI.URIAuth { URI.uriUserInfo = "", URI.uriRegName = serverUserContentHost, URI.uriPort = "" }
106-
, URI.uriPath = rqUri rq
94+
uri = serverUserContentBaseURI
95+
{ URI.uriPath = rqUri rq
10796
, URI.uriQuery = rqQuery rq
108-
, URI.uriFragment = ""
10997
}
11098
in
11199
seeOther (show uri) (toResponse ())
112100
else
113-
if hostHeaderValue /= encodeUtf8 (T.pack serverUserContentHost)
114-
then
115-
fail $
116-
"Host name (" <> maybe "N/A" T.unpack (decodeASCII' hostHeaderValue) <> ")" <>
117-
" matched neither user content host (" <> serverUserContentHost <> ")" <>
118-
" nor base host (" <> URI.uriRegName baseAuth <> ")"
119-
else pure action
120-
121-
-- | Returns Just when the host isn't matching the regular host
122-
isRegularHost :: ServerMonad m => ServerEnv -> m (Maybe (BS.ByteString, String))
123-
isRegularHost env = do
124-
mbPair <- getHostAndBaseAuth env
125-
case mbPair of
126-
Just (hostHeaderValue, baseAuth) | hostHeaderValue /= encodeUtf8 (T.pack $ URI.uriRegName baseAuth) ->
127-
pure $ Just (hostHeaderValue, URI.uriRegName baseAuth)
128-
_ -> pure Nothing
101+
pure action

0 commit comments

Comments
 (0)