230 lines
8.8 KiB
Haskell
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)
|