nuthatch/test/AuthProbe.hs

230 lines
8.8 KiB
Haskell

module Main (main) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as B8
import Data.List (find)
import System.Entropy (getEntropy)
import Nuthatch.Probe.Assert
import Nuthatch.Probe.Curl
import Nuthatch.Probe.Oidc
import Nuthatch.Probe.ProductClient
import System.IO.Temp (withSystemTempDirectory)
main :: IO ()
main =
withSystemTempDirectory "nuthatch-auth-probe" $ \directory -> do
context <- newProbeContext directory
runId <- B8.unpack . B16.encode <$> getEntropy 8
verifyCredentialScope
verifyPublicBoundary context
userA <- requireEnv "NUTHATCH_OIDC_USER_A"
sessionA <- login context True userA "a"
verifyPkceBoundary context userA
verifyAuthenticatedBoundary context sessionA
exerciseRoot context sessionA runId "personal" "01"
exerciseRoot context sessionA runId "shared" "02"
verifyCrossUserBoundary context sessionA runId
verifyLogout context sessionA
putStrLn "OIDC application boundary passed"
verifyCredentialScope :: IO ()
verifyCredentialScope = do
endpoint <- requireEnv "NUTHATCH_S3_ENDPOINT"
region <- requireEnv "NUTHATCH_S3_REGION"
accessKey <- requireEnv "NUTHATCH_S3_ACCESS_KEY"
secretKey <- requireEnv "NUTHATCH_S3_SECRET_KEY"
let signed =
[ "--aws-sigv4", "aws:amz:" <> region <> ":s3"
, "--user", accessKey <> ":" <> secretKey
]
denied label arguments = do
response <- curl (signed <> arguments)
assertEqual
(label <> ": " <> B8.unpack (curlBody response))
403
(curlStatus response)
emptyPayload =
"X-Amz-Content-Sha256:e3b0c44298fc1c149afbf4c8996fb924"
<> "27ae41e4649b934ca495991b7852b855"
denied "unscoped bucket head"
[ "--head"
, "--header", emptyPayload
, endpoint <> "/nuthatch-fixture"
]
denied "unscoped bucket listing"
[ "--header", emptyPayload
, endpoint <> "/nuthatch-fixture?list-type=2"
]
denied "unscoped object read"
[ "--header", emptyPayload
, endpoint <> "/nuthatch-fixture/scope-canary"
]
denied "unscoped object write"
[ "--request", "PUT"
, "--header"
, "X-Amz-Content-Sha256:16d02fe317ec0615c2039631cc72bdf9"
<> "e6c8b7a2cb32895d6235c5775522bbc6"
, "--data-binary", "denied-write"
, endpoint <> "/nuthatch-fixture/denied-write"
]
verifyPublicBoundary :: ProbeContext -> IO ()
verifyPublicBoundary context = do
health <- curl [probeBase context <> "/healthz"]
assertEqual "health status" 200 (curlStatus health)
assertContains "health response" "\"ok\":true" (curlBody health)
ready <- curl [probeBase context <> "/readyz"]
assertEqual "readiness status" 200 (curlStatus ready)
assertContains "readiness response" "\"ready\":true" (curlBody ready)
loginPost <- curl ["--request", "POST", probeBase context <> "/login"]
assertEqual "login method boundary" 405 (curlStatus loginPost)
root <- curl [probeBase context <> "/"]
assertEqual "root route boundary" 404 (curlStatus root)
downloadResponse <- curl
[ probeBase context
<> "/api/download?root=personal&path=unauthenticated"
]
assertEqual "unauthenticated download" 303 (curlStatus downloadResponse)
uploadResponse <- curl
[ "--header", "X-Declared-Size: 1"
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex "x")
, "--header"
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
, "--data", "x"
, probeBase context
<> "/api/upload?root=personal&path=unauthenticated"
<> "&operation_id=40000000-0000-4000-8000-000000000001"
]
assertEqual "unauthenticated upload" 303 (curlStatus uploadResponse)
deleteResponse <- curl
[ "--header", "Content-Type: application/json"
, "--data"
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
<> "\"operation_id\":\"40000000-0000-4000-8000-000000000002\","
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
, probeBase context <> "/api/delete"
]
assertEqual "unauthenticated delete" 303 (curlStatus deleteResponse)
removedCompatibility <- curl
[ "--header", "Content-Type: application/json"
, "--data"
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
, probeBase context <> "/delete-prefix"
]
assertEqual "removed compatibility route" 404 (curlStatus removedCompatibility)
logoutResponse <- curl
[ "--header"
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
, "--request", "POST"
, probeBase context <> "/logout"
]
assertEqual "unauthenticated logout" 303 (curlStatus logoutResponse)
verifyAuthenticatedBoundary :: ProbeContext -> Session -> IO ()
verifyAuthenticatedBoundary context session = do
html <- curl
[ "--cookie", sessionCookies session
, probeBase context <> "/api/browse"
]
assertEqual "authenticated HTML browse" 200 (curlStatus html)
json <- browse context session ""
assertEqual "authenticated JSON browse" 200 (curlStatus json)
let prohibited =
[ "NUTHATCH_S3"
, "AWS_ACCESS_KEY"
, "AWS_SECRET_ACCESS_KEY"
, "client_secret"
]
bodies = curlBody html <> curlBody json
mapM_
(\value ->
assert ("browser response exposed " <> value) $
not (B8.pack value `BS.isInfixOf` bodies))
prohibited
exerciseRoot
:: ProbeContext
-> Session
-> String
-> String
-> String
-> IO ()
exerciseRoot context session runId root suffix = do
let path = "smoke-" <> suffix <> ".txt"
first = B8.pack ("first-" <> root)
second = B8.pack ("second-" <> root)
operation prefix =
runId <> "-" <> prefix <> "-" <> suffix
firstUpload <- upload context session root path (operation "0") first
requireSucceeded "first upload" firstUpload
listing <- browse context session ("?root=" <> root)
assertContains "uploaded object listing" (B8.pack path) (curlBody listing)
firstDownload <- download context session root path
assertEqual "first download status" 200 (curlStatus firstDownload)
assertEqual "first download body" first (curlBody firstDownload)
overwrite <- upload context session root path (operation "1") second
requireSucceeded "overwrite" overwrite
secondDownload <- download context session root path
assertEqual "overwrite body" second (curlBody secondDownload)
deleted <- deleteObject context session root path (operation "2")
requireSucceeded "delete" deleted
missing <- download context session root path
assertEqual "deleted object" 404 (curlStatus missing)
verifyCrossUserBoundary :: ProbeContext -> Session -> String -> IO ()
verifyCrossUserBoundary context sessionA runId = do
let body = "private-a"
personal <- upload context sessionA "personal" "cross-user.txt"
(runId <> "-cross-personal") body
requireSucceeded "personal cross-user setup" personal
shared <- upload context sessionA "shared" "cross-user.txt"
(runId <> "-cross-shared") body
requireSucceeded "shared cross-user setup" shared
userB <- requireEnv "NUTHATCH_OIDC_USER_B"
sessionB <- login context False userB "b"
personalB <- browse context sessionB "?root=personal"
assert "another user observed a personal object" $
not ("cross-user.txt" `BS.isInfixOf` curlBody personalB)
sharedB <- browse context sessionB "?root=shared"
assertContains "shared object visibility" "cross-user.txt" (curlBody sharedB)
verifyLogout :: ProbeContext -> Session -> IO ()
verifyLogout context session = do
rejected <- curl
[ "--cookie", sessionCookies session
, "--header"
, "X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
, "--request", "POST"
, probeBase context <> "/logout"
]
assert "logout accepted the wrong CSRF token" (curlStatus rejected >= 400)
completed <- curl
[ "--cookie", sessionCookies session
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
, "--request", "POST"
, probeBase context <> "/logout"
]
assertEqual "logout status" 200 (curlStatus completed)
mapM_
(\name ->
let matching =
find
(BS.isPrefixOf (name <> "="))
(headerValues "set-cookie" completed)
in assert (B8.unpack name <> " was not expired") $
maybe False
(BS.isInfixOf "Max-Age=0")
matching)
["nuthatch_session", "nuthatch_csrf", "nuthatch_oidc"]
after <- curl
[ "--header", "Accept: application/json"
, probeBase context <> "/api/browse"
]
assertEqual "session after logout" 303 (curlStatus after)
requireSucceeded :: String -> CurlResponse -> IO ()
requireSucceeded label response = do
assertEqual (label <> " status") 200 (curlStatus response)
assertContains label "\"result\":\"succeeded\"" (curlBody response)