wai-app-file-cgi 3.2.3 → 3.3.0
raw patch · 9 files changed
+138/−12 lines, 9 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- CHANGELOG.md +14/−0
- Network/Wai/Application/Classic/CGI.hs +8/−3
- Network/Wai/Application/Classic/File.hs +5/−1
- Network/Wai/Application/Classic/FileInfo.hs +54/−1
- Network/Wai/Application/Classic/Redirect.hs +4/−2
- Network/Wai/Application/Classic/RevProxy.hs +8/−4
- test/ClassicSpec.hs +36/−0
- test/Spec.hs +8/−0
- wai-app-file-cgi.cabal +1/−1
CHANGELOG.md view
@@ -1,5 +1,19 @@ # ChangeLog for wai-app-file-cgi +## 3.3.0++- **Breaking**: `rawPathInfo` is percent-decoded in `fileApp` and+ `cgiApp`, including `PATH_INFO` and `SCRIPT_NAME`. Callers that+ decode it beforehand (e.g. mighttpd2 up to now) must pass the+ original request; otherwise paths containing `%` are decoded+ twice.+- **Breaking**: paths that do not match the source of a route are+ rejected with 404 in all applications. A source with a trailing+ slash also matches the path without it.+- **Breaking**: paths containing a segment starting with `.` (e.g.+ `.git`), NUL, or encoded `..` are rejected with 400 in `fileApp`+ and `cgiApp`. `/.well-known/` is allowed.+ ## 3.2.3 - Security: request headers containing `_` are not passed to CGI
Network/Wai/Application/Classic/CGI.hs view
@@ -22,6 +22,7 @@ import Network.Wai.Conduit import Network.Wai.Application.Classic.Conduit import Network.Wai.Application.Classic.Field+import Network.Wai.Application.Classic.FileInfo (decodedPathInfo, isUnsafePath, matchRoute) import Network.Wai.Application.Classic.Header import Network.Wai.Application.Classic.Path import Network.Wai.Application.Classic.Types@@ -45,24 +46,28 @@ {-| Handle any methods for CGI. +Paths containing a segment starting with \'.\' are rejected with 400.+ CGI processes are reaped by this library. The program to link this library should not ignore SIGCHLD. Otherwise, the PID of a finished CGI process may be reused by another process before it is terminated. -} cgiApp :: ClassicAppSpec -> CgiAppSpec -> CgiRoute -> Application cgiApp cspec spec cgii req respond- | hasDotDotSegment (rawPathInfo req) =+ | isUnsafePath (rawPathInfo req) = respond $ responseLBS badRequest400 textPlainHeader "Bad Request\r\n"+ | not (matchRoute (cgiSrc cgii) (decodedPathInfo req)) = notFound | otherwise = do exist <- doesFileExist prog if exist then E.bracket setup teardown cgi- else respond $ responseLBS notFound404 textPlainHeader "Not Found\r\n"+ else notFound where+ notFound = respond $ responseLBS notFound404 textPlainHeader "Not Found\r\n" cgiPath@(prog,_,_) = pathinfoToCGI (cgiSrc cgii) (cgiDst cgii)- (rawPathInfo req)+ (decodedPathInfo req) (indexCgi spec) setup = execProcess cspec cgiPath req -- An unreaped process keeps its PID, so SIGTERM cannot hit
Network/Wai/Application/Classic/File.hs view
@@ -50,6 +50,9 @@ -- If 'rawPathInfo' does not end with \'/\' and a corresponding index file -- exist, redirection is specified in HTTP response. --+-- Paths containing a segment starting with \'.\' (e.g. \".git\")+-- are rejected with 400, except \"\/.well-known\/\".+-- -- Directory contents are NOT automatically listed. To list directory -- contents, an index file must be created beforehand. --@@ -58,7 +61,8 @@ fileApp :: ClassicAppSpec -> FileAppSpec -> FileRoute -> Application fileApp cspec spec filei req respond = do rspspec <- case method of- _ | hasDotDotSegment (rawPathInfo req) -> return badRequest+ _ | isUnsafePath (rawPathInfo req) -> return badRequest+ _ | not (matchRoute (fileSrc filei) (decodedPathInfo req)) -> return notFound Right GET -> processGET hinfo ishtml rfile Right HEAD -> processGET hinfo ishtml rfile _ -> return notAllowed
Network/Wai/Application/Classic/FileInfo.hs view
@@ -1,6 +1,9 @@+{-# LANGUAGE OverloadedStrings #-}+ module Network.Wai.Application.Classic.FileInfo where import qualified Data.ByteString as BS+import Network.HTTP.Types (urlDecode) import Network.Wai import Network.Wai.Application.Classic.Path import Network.Wai.Application.Classic.Types@@ -37,15 +40,65 @@ -- "/tmp/dst/" -- >>> pathinfoToFilePath (mkReq "/exact/match/more.html") (FileRoute "/exact/match" "/tmp/dst") -- "/tmp/dst/more.html"+-- >>> pathinfoToFilePath (mkReq "/no%5Fext%20ension") (FileRoute "/" "/srv/http/")+-- "/srv/http/no_ext ension" pathinfoToFilePath :: Request -> FileRoute -> Path pathinfoToFilePath req filei | BS.null path' = dst | otherwise = dst </> path' where- path = rawPathInfo req+ path = decodedPathInfo req src = fileSrc filei dst = fileDst filei path' = path <\> src++-- | 'rawPathInfo' with percent-encoding decoded.+decodedPathInfo :: Request -> Path+decodedPathInfo = urlDecode False . rawPathInfo++-- | Whether the path is routed by the source of a route.+-- A source with a trailing slash also matches the path without it.+--+-- >>> matchRoute "/static/" "/static/a.html"+-- True+-- >>> matchRoute "/static/" "/static"+-- True+-- >>> matchRoute "/static/" "/other/static/a.html"+-- False+-- >>> matchRoute "/exact/match" "/exact/match/more.html"+-- True+matchRoute :: Path -> Path -> Bool+matchRoute src path =+ src `BS.isPrefixOf` path+ || hasTrailingPathSeparator src && path == BS.init src++-- | Whether the path, once decoded, contains NUL or a segment+-- starting with \'.\' (e.g. ".." and ".git").+-- \"\/.well-known\/\" is allowed for ACME.+--+-- >>> isUnsafePath "/foo/%2e%2e/bar"+-- True+-- >>> isUnsafePath "/foo%00.html"+-- True+-- >>> isUnsafePath "/foo/..bar"+-- True+-- >>> isUnsafePath "/%2egit/config"+-- True+-- >>> isUnsafePath "/foo.d/bar.html"+-- False+-- >>> isUnsafePath "/.well-known/acme-challenge/x"+-- False+-- >>> isUnsafePath "/.well-known/.git/config"+-- True+isUnsafePath :: Path -> Bool+isUnsafePath raw = BS.elem 0 path || any isHidden segs'+ where+ path = urlDecode False raw+ segs = BS.splitWith (\w -> w == 47 || w == 92) path -- '/' and '\\'+ segs' = case segs of+ "" : ".well-known" : rest -> rest+ _ -> segs+ isHidden seg = "." `BS.isPrefixOf` seg -- | --
Network/Wai/Application/Classic/Redirect.hs view
@@ -8,12 +8,14 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Application.Classic.Field+import Network.Wai.Application.Classic.FileInfo (matchRoute) import Network.Wai.Application.Classic.Path import Network.Wai.Application.Classic.Types redirectApp :: ClassicAppSpec -> RedirectRoute -> Application-redirectApp _ route req respond =- respond $ responseLBS status hdr ""+redirectApp _ route req respond+ | matchRoute src path = respond $ responseLBS status hdr ""+ | otherwise = respond $ responseLBS notFound404 textPlainHeader "Not Found\r\n" where path = rawPathInfo req src = redirectSrc route
Network/Wai/Application/Classic/RevProxy.hs view
@@ -17,6 +17,7 @@ import Network.Wai.Application.Classic.Conduit import Network.Wai.Application.Classic.EventSource import Network.Wai.Application.Classic.Field+import Network.Wai.Application.Classic.FileInfo (matchRoute) import Network.Wai.Application.Classic.Path import Network.Wai.Application.Classic.Types import Network.Wai.Conduit@@ -28,10 +29,13 @@ -- | Relaying any requests as reverse proxy. revProxyApp :: ClassicAppSpec -> RevProxyAppSpec -> RevProxyRoute -> Application-revProxyApp cspec spec route req respond = do- responded <- newIORef False- H.withResponse httpClientRequest mgr (proxy responded)- `E.catch` badGateway responded+revProxyApp cspec spec route req respond+ | not (matchRoute (revProxySrc route) (rawPathInfo req)) =+ respond $ responseLBS notFound404 textPlainHeader "Not Found\r\n"+ | otherwise = do+ responded <- newIORef False+ H.withResponse httpClientRequest mgr (proxy responded)+ `E.catch` badGateway responded where proxy responded hrsp = do let status = H.responseStatus hrsp
test/ClassicSpec.hs view
@@ -18,6 +18,10 @@ let url = "http://127.0.0.1:2345/cgi-bin/" bdy <- rspBody <$> sendGET url bdy `shouldBe` "index.cgi\n"+ it "decodes PATH_INFO" $ do+ let url = "http://127.0.0.1:2345/cgi-bin/index.cgi/a%20b"+ bdy <- rspBody <$> sendGET url+ bdy `shouldBe` "PATH_INFO: /a b\n" it "accepts POST" $ do let url = "http://127.0.0.1:2345/cgi-bin/echo-env/pathinfo?query=foo" bdy <- rspBody <$> sendPOST url "foo bar.\nbaz!\n"@@ -61,6 +65,10 @@ let url = "http://127.0.0.1:2345/cgi-bin/broken" sc <- rspCode <$> sendPOST url "foo bar.\nbaz!\n" sc `shouldBe` (5, 0, 0)+ it "returns 400 for hidden files" $ do+ let url = "http://127.0.0.1:2345/cgi-bin/%2ehidden"+ sc <- rspCode <$> sendGET url+ sc `shouldBe` (4, 0, 0) it "returns 404 if the CGI does not exist" $ do let url = "http://127.0.0.1:2345/cgi-bin/nonexistent" sc <- rspCode <$> sendGET url@@ -89,6 +97,12 @@ bdy <- bracket_ (writeFile file "hello\n") (removeFile file) $ H.responseBody <$> H.httpLbs hreq' mgr bdy `shouldBe` "hello\n"+ it "serves files with percent-encoded non-ASCII names" $ do+ let url = "http://127.0.0.1:2345/%E6%97%A5%E6%9C%AC.txt"+ file = "test/html/\26085\26412.txt"+ bdy <- bracket_ (writeFile file "hello\n") (removeFile file) $+ rspBody <$> sendGET url+ bdy `shouldBe` "hello\n" it "returns 404 if not exist" $ do let url = "http://127.0.0.1:2345/dummy" sc <- rspCode <$> sendGET url@@ -97,6 +111,23 @@ let url = "http://127.0.0.1:2345/../../../../../../etc/hosts" sc <- rspCode <$> sendGET url sc `shouldBe` (4, 0, 0)+ it "returns 400 if the path contains encoded .." $ do+ let url = "http://127.0.0.1:2345/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/hosts"+ sc <- rspCode <$> sendGET url+ sc `shouldBe` (4, 0, 0)+ it "returns 400 for hidden files even if encoded" $ do+ sc1 <- rspCode <$> sendGET "http://127.0.0.1:2345/.git/config"+ sc1 `shouldBe` (4, 0, 0)+ sc2 <- rspCode <$> sendGET "http://127.0.0.1:2345/%2egit/config"+ sc2 `shouldBe` (4, 0, 0)+ it "does not reject /.well-known/" $ do+ sc <- rspCode <$> sendGET "http://127.0.0.1:2345/.well-known/acme-challenge/x"+ sc `shouldBe` (4, 0, 4)+ it "decodes percent-encoded paths" $ do+ let url = "http://127.0.0.1:2345/no%5Fextension"+ bdy <- rspBody <$> sendGET url+ ans <- readFileAscii "test/html/no_extension"+ bdy `shouldBe` ans it "returns Japanese HTML if language is specified" $ do let url = "http://127.0.0.1:2345/ja/" bdy <- rspBody <$> sendGETwH url [Header HdrAcceptLanguage "ja, en;q=0.7"]@@ -156,6 +187,11 @@ rsp <- sendGET url lookupHeader HdrLocation (rspHeaders rsp) `shouldBe` Just "//127.0.0.1:2345/redirect/?foo=bar"+ it "returns 404 if the path does not match the route" $ do+ -- "/mismatch" has the same length as "/statics/".+ let url = "http://127.0.0.1:2345/mismatchindex.html"+ sc <- rspCode <$> sendGET url+ sc `shouldBe` (4, 0, 4) it "returns index.html for /exact-route.html matching exactRoute" $ do let url = "http://127.0.0.1:2345/exact-route.html" bdy <- rspBody <$> sendGET url
test/Spec.hs view
@@ -50,6 +50,7 @@ | upstream = revProxyApp appSpec (RevProxyAppSpec mgr) upstreamRoute req | redirect = redirectApp appSpec redirectRoute req | cgi = cgiApp appSpec defaultCgiAppSpec cgiRoute req+ | mismatch = fileApp appSpec defaultFileAppSpec mismatchRoute req | exact = fileApp appSpec defaultFileAppSpec exactRoute req | otherwise = fileApp appSpec defaultFileAppSpec fileRoute req where@@ -57,6 +58,7 @@ upstream = "/upstream/" `BS.isPrefixOf` rawPathInfo req redirect = "/redirect-app/" `BS.isPrefixOf` rawPathInfo req cgi = "/cgi-bin/" `BS.isPrefixOf` rawPathInfo req+ mismatch = "/mismatch" `BS.isPrefixOf` rawPathInfo req exact = "/exact-route.html" `BS.isPrefixOf` rawPathInfo req appSpec = defaultClassicAppSpec{softwareName = "ClassicTester"} redirectRoute =@@ -73,6 +75,12 @@ FileRoute { fileSrc = "/exact-route.html" , fileDst = fromString (dir </> "test/html/index.html")+ }+ -- Dispatched although the source does not match.+ mismatchRoute =+ FileRoute+ { fileSrc = "/statics/"+ , fileDst = fromString (dir </> "test/html/") } fileRoute = FileRoute
wai-app-file-cgi.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.18 name: wai-app-file-cgi-version: 3.2.3+version: 3.3.0 license: BSD3 license-file: LICENSE maintainer: Kazu Yamamoto <kazu@iij.ad.jp>