add support for multiple domains in cors config (close #1436) (#1536)

Support for multiple domains (as CSV) in the `--cors-domain` flag and `HASURA_GRAPHQL_CORS_DOMAIN` env var.

Following are all valid configurations (must include scheme and optional port):
```shell
HASURA_GRAPHQL_CORS_DOMAIN="https://*.foo.bar.com:8080"
HASURA_GRAPHQL_CORS_DOMAIN="https://*.foo.bar.com, http://*.localhost, https://example.com"
HASURA_GRAPHQL_CORS_DOMAIN="*"
HASURA_GRAPHQL_CORS_DOMAIN="http://example.com, http://*.localhost, http://localhost:3000, https://*.foo.bar.com, https://foo.bar.com"
```

**Note**: top-level domains are not considered as part of wildcard domains. You have to add them separately. E.g - `https://*.foo.com` doesn't include `https://foo.com`.

The default (if the flag or env var is not specified) is `*`. Which means CORS headers are sent for all domains.
This commit is contained in:
Anon Ray 2019-02-14 05:58:38 +00:00 committed by Shahidh K Muhammed
parent 68bb898b24
commit 199a24d050
13 changed files with 447 additions and 191 deletions

View File

@ -119,6 +119,8 @@ WH_PID=""
trap stop_services ERR
trap stop_services INT
# test without access key
echo -e "\n<########## TEST GRAPHQL-ENGINE WITHOUT ACCESS KEYS ###########################################>\n"
"$GRAPHQL_ENGINE" serve > "$OUTPUT_FOLDER/graphql-engine.log" & PID=$!
@ -131,7 +133,9 @@ kill -INT $PID
sleep 4
mv graphql-engine.tix graphql-engine-combined.tix || true
##########
# test with access key
echo -e "\n<########## TEST GRAPHQL-ENGINE WITH ACCESS KEY #####################################>\n"
export HASURA_GRAPHQL_ACCESS_KEY="HGE$RANDOM$RANDOM"
@ -146,7 +150,9 @@ kill -INT $PID
sleep 4
combine_hpc_reports
##########
# test with jwt
echo -e "\n<########## TEST GRAPHQL-ENGINE WITH ACCESS KEY AND JWT #####################################>\n"
init_jwt
@ -177,7 +183,24 @@ combine_hpc_reports
unset HASURA_GRAPHQL_JWT_SECRET
##########
# test with CORS modes
echo -e "\n<########## TEST GRAPHQL-ENGINE WITH CORS DOMAINS ########>\n"
export HASURA_GRAPHQL_CORS_DOMAIN="http://*.localhost, http://localhost:3000, https://*.foo.bar.com"
"$GRAPHQL_ENGINE" serve >> "$OUTPUT_FOLDER/graphql-engine.log" 2>&1 & PID=$!
wait_for_port 8080
pytest -vv --hge-url="$HGE_URL" --pg-url="$HASURA_GRAPHQL_DATABASE_URL" --hge-key="$HASURA_GRAPHQL_ACCESS_KEY" --test-cors test_cors.py
kill -INT $PID
sleep 4
combine_hpc_reports
unset HASURA_GRAPHQL_CORS_DOMAIN
# webhook tests
if [ $EUID != 0 ] ; then
echo -e "SKIPPING webhook based tests, as \nroot permission is required for running webhook tests (inorder to trust certificate authority)."
@ -248,6 +271,8 @@ if [ "$RUN_WEBHOOK_TESTS" == "true" ] ; then
combine_hpc_reports
kill $WH_PID
fi
mv graphql-engine-combined.tix "$OUTPUT_FOLDER/graphql-engine.tix" || true

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ npm-debug.log
test-server-output
test-server-flags-output
.vscode
.idea

View File

@ -94,14 +94,33 @@ You can also set the access key using a flag to the command:
Configure CORS
--------------
By default, all CORS requests are allowed. To run Hasura with more restrictive CORS settings, use the ``--cors-domain`` flag.
By default, all CORS requests to Hasura GraphQL engine are allowed. To run with more restrictive CORS settings,
use the ``--cors-domain`` flag or the ``HASURA_GRAPHQL_CORS_DOMAIN`` ENV variable. The default value is ``*``,
which means CORS headers are sent for all domains.
For example:
Scheme + host with optional wildcard + optional port has to be mentioned.
Examples:
.. code-block:: bash
docker run -P -d hasura/graphql-engine:latest graphql-engine \
--database-url postgres://username:password@host:5432/dbname \
serve \
--access-key XXXXXXXXXXXXXXXX
--cors-domain https://mywebsite.com:8090
# Accepts from https://app.foo.bar.com , https://api.foo.bar.com etc.
HASURA_GRAPHQL_CORS_DOMAIN="https://*.foo.bar.com"
# Accepts from https://app.foo.bar.com:8080 , http://api.foo.bar.com:8080,
# http://app.localhost, http://api.localhost, http://localhost:3000,
# http://example.com etc.
HASURA_GRAPHQL_CORS_DOMAIN="https://*.foo.bar.com:8080, http://*.localhost, http://localhost:3000, http://example.com"
# Accepts from all domain
HASURA_GRAPHQL_CORS_DOMAIN="*"
# Accepts only from http://example.com
HASURA_GRAPHQL_CORS_DOMAIN="http://example.com"
.. note::
Top-level domains are not considered as part of wildcard domains. You
have to add them separately. E.g - ``https://*.foo.com`` doesn't include
``https://foo.com``.

View File

@ -12,18 +12,30 @@ Every GraphQL engine command is structured as:
$ graphql-engine <server-flags> serve <command-flags>
The flags can be passed as ENV variables as well.
Server flags
^^^^^^^^^^^^
For ``graphql-engine`` command these are the flags available
For ``graphql-engine`` command these are the flags and ENV variables available:
.. code-block:: none
--database-url Postgres database URL
<postgres/postgresql>://<user>:<password>@<host>:<port>/<db-name>
Example: postgres://admin:mypass@mydomain.com:5432/mydb
.. list-table::
:header-rows: 1
Or either you can specify following options
* - Flag
- ENV variable
- Description
* - ``--database-url <DB_URL>``
- ``HASURA_GRAPHQL_DATABASE_URL``
- Postgres database URL:
``postgres://<user>:<password>@<host>:<port>/<db-name>``
Example: ``postgres://admin:mypass@mydomain.com:5432/mydb``
Or you can specify following options *(only via flags)*
.. code-block:: none
@ -33,123 +45,100 @@ Or either you can specify following options
-p, --password Password of the user
-d, --dbname Database name to connect to
Command flags
^^^^^^^^^^^^^
For ``serve`` subcommand these are the flags available
For ``serve`` sub-command these are the flags and ENV variables available:
.. code-block:: none
.. list-table::
:header-rows: 1
--server-host IP address of network interface that graphql-engine will listen on (default: '*', all interfaces)
* - Flag
- ENV variable
- Description
--server-port Port on which graphql-engine should be served (default: 8080)
* - ``--server-port <PORT>``
- ``HASURA_GRAPHQL_SERVER_PORT``
- Port on which graphql-engine should be served (default: 8080)
--access-key Secret access key, required to access this instance.
If specified client needs to send 'X-Hasura-Access-Key'
header
* - ``--server-host <HOST>``
- ``HASURA_GRAPHQL_SERVER_HOST``
- Host on which graphql-engine will listen (default: ``*``)
--cors-domain The domain, including sheme and port, to allow CORS for
* - ``--enable-console <true|false>``
- ``HASURA_GRAPHQL_ENABLE_CONSOLE``
- Enable the Hasura Console (served by the server on ``/`` and ``/console``)
--disable-cors Disable CORS handling
* - ``--access-key <SECRET_ACCESS_KEY>``
- ``HASURA_GRAPHQL_ACCESS_KEY``
- Secret access key, for admin access to this instance. This is mandatory
when you use webhook or JWT.
--auth-hook The authentication webhook, required to authenticate
incoming request
* - ``--auth-hook <WEBHOOK_URL>``
- ``HASURA_GRAPHQL_AUTH_HOOK``
- URL of the authorization webhook required to authorize requests.
See auth webhooks docs for more details.
--auth-hook-mode The authentication webhook mode. GET|POST (default: GET)
* - ``--auth-hook-mode <GET|POST>``
- ``HASURA_GRAPHQL_AUTH_HOOK_MODE``
- HTTP method to use for the authorization webhook (default: GET)
--jwt-secret The JSON containing type and the JWK used for
verifying. e.g: `{"type": "HS256", "key":
"<your-hmac-shared-secret>"}`,`{"type": "RS256",
"key": "<your-PEM-RSA-public-key>"}
* - ``--jwt-secret <JSON_CONFIG>``
- ``HASURA_GRAPHQL_JWT_SECRET``
- A JSON string containing type and the JWK used for verifying (and other
optional details).
Example: ``{"type": "HS256", "key": "3bd561c37d214b4496d09049fadc542c"}``.
See the JWT docs for more details.
--unauthorized-role Unauthorized role, used when access-key is not sent in
access-key only mode or "Authorization" header is absent
in JWT mode
* - ``--unauthorized-role <ROLE>``
- ``HASURA_GRAPHQL_UNAUTHORIZED_ROLE``
- Unauthorized role, used when access-key is not sent in access-key only
mode or "Authorization" header is absent in JWT mode.
Example: ``anonymous``. Now whenever "Authorization" header is
absent, request's role will default to "anonymous".
-s, --stripes Number of stripes (default: 1)
* - ``--cors-domain <DOMAINS>``
- ``HASURA_GRAPHQL_CORS_DOMAIN``
- CSV of list of domains, excluding scheme (http/https) and including port,
to allow CORS for. Wildcard domains are allowed.
-c, --connections Number of connections that need to be opened to Postgres
(default: 50)
* - ``--disable-cors``
- N/A
- Disable CORS. Do not send any CORS headers on any request.
--timeout Each connection's idle time before it is closed
(default: 180 sec)
* - ``--enable-telemetry <true|false>``
- ``HASURA_GRAPHQL_ENABLE_TELEMETRY``
- Enable anonymous telemetry (default: true)
-i, --tx-iso Transaction isolation. read-commited / repeatable-read /
serializable
* - N/A
- ``HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE``
- Max event threads
--enable-console Enable API console. It is served at '/' and '/console'
* - N/A
- ``HASURA_GRAPHQL_EVENTS_FETCH_INTERVAL``
- Postgres events polling interval
--use-prepared-statements Use prepared statements for SQL queries (default: true)
* - ``-s, --stripes <NO_OF_STRIPES>``
- ``HASURA_GRAPHQL_PG_STRIPES``
- Number of conns that need to be opened to Postgres (default: 1)
--enable-telemetry Enable anonymous telemetry (default: true)
* - ``-c, --connections <NO_OF_CONNS>``
- ``HASURA_GRAPHQL_PG_CONNECTIONS``
- Number of conns that need to be opened to Postgres (default: 50)
* - ``--timeout <SECONDS>``
- ``HASURA_GRAPHQL_PG_TIMEOUT``
- Each connection's idle time before it is closed (default: 180 sec)
Default environment variables
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* - ``--use-prepared-statements <true|false>``
- ``HASURA_GRAPHQL_USE_PREPARED_STATEMENTS``
- Use prepared statements for queries (default: true)
* - ``-i, --tx-iso <TXISO>``
- ``HASURA_GRAPHQL_TX_ISOLATION``
- transaction isolation. read-committed / repeatable-read / serializable (default: read-commited)
You can use environment variables to configure defaults instead of using flags:
.. note::
When the equivalent flags for environment variables are used, the flags will take precedence.
For example:
.. code-block:: bash
$ HASURA_GRAPHQL_DATABASE_URL=postgres://user:pass@host:5432/dbname graphql-engine serve
These are the environment variables which are available:
.. code-block:: none
HASURA_GRAPHQL_DATABASE_URL Postgres database URL
<postgres/postgresql>://<user>:<password>@<host>:
<port>/<db-name> Example:
postgres://admin:mypass@mydomain.com:5432/mydb
HASURA_GRAPHQL_PG_STRIPES Number of stripes (default: 1)
HASURA_GRAPHQL_PG_CONNECTIONS Number of connections that need to be opened to
Postgres (default: 50)
HASURA_GRAPHQL_PG_TIMEOUT Each connection's idle time before it is closed
(default: 180 sec)
HASURA_GRAPHQL_TX_ISOLATION transaction isolation. read-committed /
repeatable-read / serializable
(default: read-commited)
HASURA_GRAPHQL_SERVER_HOST IP address of network interface that graphql-engine will listen on
HASURA_GRAPHQL_SERVER_PORT Port on which graphql-engine should be served
HASURA_GRAPHQL_ACCESS_KEY Secret access key, required to access this
instance. If specified client needs to send
'X-Hasura-Access-Key' header
HASURA_GRAPHQL_AUTH_HOOK The authentication webhook, required to
authenticate incoming request
HASURA_GRAPHQL_AUTH_HOOK_MODE The authentication webhook mode, GET|POST
(default: GET)
HASURA_GRAPHQL_CORS_DOMAIN The domain, including sheme and port,
to allow CORS for
HASURA_GRAPHQL_JWT_SECRET The JSON containing type and the JWK used for
verifying. e.g: `{"type": "HS256", "key":
"<your-hmac-shared-secret>"}`,`{"type": "RS256",
"key": "<your-PEM-RSA-public-key>"}
Enable JWT mode, the value of which is a JSON
HASURA_GRAPHQL_UNAUTHORIZED_ROLE Unauthorized role, used when access-key is not sent
in access-key only mode or "Authorization" header
is absent in JWT mode
HASURA_GRAPHQL_ENABLE_CONSOLE Enable API console. It is served at
'/' and '/console'
HASURA_GRAPHQL_ENABLE_TELEMETRY Enable anonymous telemetry (default: true)
HASURA_GRAPHQL_USE_PREPARED_STATEMENTS Use prepared statements for SQL queries
(default: true)
When the equivalent flags for environment variables are used, the flags will take precedence.

View File

@ -146,6 +146,7 @@ library
, Hasura.Server.Logging
, Hasura.Server.Query
, Hasura.Server.Utils
, Hasura.Server.Cors
, Hasura.Server.Version
, Hasura.Server.CheckUpdates
, Hasura.Server.Telemetry

View File

@ -43,10 +43,10 @@ import Hasura.RQL.DML.QueryTemplate
import Hasura.RQL.Types
import Hasura.Server.Auth (AuthMode (..),
getUserInfo)
import Hasura.Server.Cors
import Hasura.Server.Init
import Hasura.Server.Logging
import Hasura.Server.Middleware (corsMiddleware,
mkDefaultCorsPolicy)
import Hasura.Server.Middleware (corsMiddleware)
import Hasura.Server.Query
import Hasura.Server.Utils
import Hasura.Server.Version
@ -314,8 +314,8 @@ mkWaiApp isoLevel loggerCtx pool httpManager mode corsCfg enableConsole enableTe
httpApp :: CorsConfig -> ServerCtx -> Bool -> Bool -> SpockT IO ()
httpApp corsCfg serverCtx enableConsole enableTelemetry = do
-- cors middleware
unless (ccDisabled corsCfg) $
middleware $ corsMiddleware (mkDefaultCorsPolicy $ ccDomain corsCfg)
unless (isCorsDisabled corsCfg) $
middleware $ corsMiddleware (mkDefaultCorsPolicy corsCfg)
-- API Console and Root Dir
when enableConsole serveApiConsole

View File

@ -12,6 +12,7 @@ import Data.ASN1.Types (ASN1 (End, IntVal, Start),
import Data.Int (Int64)
import Hasura.Prelude
import Hasura.Server.Utils (fmapL)
import qualified Data.ByteString.Lazy as BL
import qualified Data.PEM as PEM
@ -97,10 +98,6 @@ pubKeyToJwk pubKey = do
RSAKeyParameters (Base64Integer n) (Base64Integer e) Nothing
fmapL :: (a -> a') -> Either a b -> Either a' b
fmapL fn (Left e) = Left (fn e)
fmapL _ (Right x) = pure x
getAtleastOne :: Text -> [a] -> Either Text a
getAtleastOne err [] = Left err
getAtleastOne _ (x:_) = Right x

View File

@ -0,0 +1,153 @@
{-# LANGUAGE DeriveAnyClass #-}
-- | CORS (Cross Origin Resource Sharing) related configuration
module Hasura.Server.Cors
( CorsConfig (..)
, CorsPolicy (..)
, parseOrigin
, readCorsDomains
, mkDefaultCorsPolicy
, isCorsDisabled
, Domains (..)
) where
import Hasura.Prelude
import Hasura.Server.Utils (fmapL)
import Control.Applicative (optional)
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J
import qualified Data.Aeson.TH as J
import qualified Data.Attoparsec.Text as AT
import qualified Data.HashSet as Set
import qualified Data.Text as T
data DomainParts =
DomainParts
{ wdScheme :: !Text
, wdHost :: !Text -- the hostname part (without the *.)
, wdPort :: !(Maybe Int)
} deriving (Show, Eq, Generic, Hashable)
$(J.deriveToJSON (J.aesonDrop 2 J.snakeCase) ''DomainParts)
data Domains
= Domains
{ dmFqdns :: !(Set.HashSet Text)
, dmWildcards :: !(Set.HashSet DomainParts)
} deriving (Show, Eq)
$(J.deriveToJSON (J.aesonDrop 2 J.snakeCase) ''Domains)
data CorsConfig
= CCAllowAll
| CCAllowedOrigins Domains
| CCDisabled
deriving (Show, Eq)
instance J.ToJSON CorsConfig where
toJSON c = case c of
CCDisabled -> toJ True J.Null
CCAllowAll -> toJ False (J.String "*")
CCAllowedOrigins d -> toJ False $ J.toJSON d
where
toJ dis origs =
J.object [ "disabled" J..= dis
, "allowed_origins" J..= origs
]
isCorsDisabled :: CorsConfig -> Bool
isCorsDisabled = \case
CCDisabled -> True
_ -> False
readCorsDomains :: String -> Either String CorsConfig
readCorsDomains str
| str == "*" = pure CCAllowAll
| otherwise = do
let domains = map T.strip $ T.splitOn "," (T.pack str)
pDomains <- mapM parseOptWildcardDomain domains
let (fqdns, wcs) = (lefts pDomains, rights pDomains)
return $ CCAllowedOrigins $ Domains (Set.fromList fqdns) (Set.fromList wcs)
data CorsPolicy
= CorsPolicy
{ cpConfig :: !CorsConfig
, cpMethods :: ![Text]
, cpMaxAge :: !Int
} deriving (Show, Eq)
mkDefaultCorsPolicy :: CorsConfig -> CorsPolicy
mkDefaultCorsPolicy cfg =
CorsPolicy
{ cpConfig = cfg
, cpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
, cpMaxAge = 1728000
}
-- | Parsers for wildcard domains
runParser :: AT.Parser a -> Text -> Either String a
runParser = AT.parseOnly
parseOrigin :: Text -> Either String DomainParts
parseOrigin = runParser originParser
originParser :: AT.Parser DomainParts
originParser =
domainParser (Just ignoreSubdomain)
where ignoreSubdomain = do
s <- AT.takeTill (== '.')
void $ AT.char '.'
return s
parseOptWildcardDomain :: Text -> Either String (Either Text DomainParts)
parseOptWildcardDomain d =
fmapL (const errMsg) $ runParser optWildcardDomainParser d
where
optWildcardDomainParser :: AT.Parser (Either Text DomainParts)
optWildcardDomainParser =
Right <$> wildcardDomainParser <|> Left <$> fqdnParser
errMsg = "invalid domain: '" <> T.unpack d <> "'. " <> helpMsg
helpMsg = "All domains should have scheme + (optional wildcard) host + "
<> "(optional port)"
wildcardDomainParser :: AT.Parser DomainParts
wildcardDomainParser = domainParser $ Just (AT.string "*" *> AT.string ".")
fqdnParser :: AT.Parser Text
fqdnParser = do
(DomainParts scheme host port) <- domainParser Nothing
let sPort = maybe "" (\p -> ":" <> T.pack (show p)) port
return $ scheme <> host <> sPort
domainParser :: Maybe (AT.Parser Text) -> AT.Parser DomainParts
domainParser parser = do
scheme <- schemeParser
forM_ parser void
host <- hostPortParser
port <- optional portParser
return $ DomainParts scheme host port
where
schemeParser :: AT.Parser Text
schemeParser = AT.string "http://" <|> AT.string "https://"
hostPortParser :: AT.Parser Text
hostPortParser = hostWithPortParser <|> AT.takeText
hostWithPortParser :: AT.Parser Text
hostWithPortParser = do
h <- AT.takeWhile1 (/= ':')
void $ AT.char ':'
return h
portParser :: AT.Parser Int
portParser = AT.decimal

View File

@ -8,14 +8,17 @@ import System.Exit (exitFailure)
import qualified Data.Aeson as J
import qualified Data.String as DataString
import qualified Data.Text as T
import qualified Hasura.Logging as L
import qualified Text.PrettyPrint.ANSI.Leijen as PP
import Hasura.Prelude
import Hasura.RQL.Types (RoleName (..))
import Hasura.Server.Auth
import Hasura.Server.Cors
import Hasura.Server.Logging
import Hasura.Server.Utils
import Network.Wai.Handler.Warp
import qualified Text.PrettyPrint.ANSI.Leijen as PP
import qualified Hasura.Logging as L
initErrExit :: (Show e) => e -> IO a
@ -41,20 +44,11 @@ data RawServeOptions
, rsoAuthHook :: !RawAuthHook
, rsoJwtSecret :: !(Maybe Text)
, rsoUnAuthRole :: !(Maybe RoleName)
, rsoCorsConfig :: !RawCorsConfig
, rsoCorsConfig :: !(Maybe CorsConfig)
, rsoEnableConsole :: !Bool
, rsoEnableTelemetry :: !(Maybe Bool)
} deriving (Show, Eq)
data CorsConfigG a
= CorsConfigG
{ ccDomain :: !a
, ccDisabled :: !Bool
} deriving (Show, Eq)
type RawCorsConfig = CorsConfigG (Maybe T.Text)
type CorsConfig = CorsConfigG T.Text
data ServeOptions
= ServeOptions
{ soPort :: !Int
@ -133,6 +127,9 @@ instance FromEnv Bool where
instance FromEnv Q.TxIsolation where
fromEnv = readIsoLevel
instance FromEnv CorsConfig where
fromEnv = readCorsDomains
parseStrAsBool :: String -> Either String Bool
parseStrAsBool t
| t `elem` truthVals = Right True
@ -244,9 +241,8 @@ mkServeOptions rso = do
authHookTyEnv mType = fromMaybe AHTGet <$>
withEnv mType "HASURA_GRAPHQL_AUTH_HOOK_TYPE"
mkCorsConfig (CorsConfigG mDom isDis) = do
domEnv <- fromMaybe "*" <$> withEnv mDom (fst corsDomainEnv)
return $ CorsConfigG domEnv isDis
mkCorsConfig mCfg =
fromMaybe CCAllowAll <$> withEnv mCfg (fst corsDomainEnv)
mkExamplesDoc :: [[String]] -> PP.Doc
mkExamplesDoc exampleLines =
@ -308,6 +304,9 @@ serveCmdFooter =
, [ "# Start GraphQL Engine with restrictive CORS policy (only allow https://example.com:8080)"
, "graphql-engine --database-url <database-url> serve --cors-domain https://example.com:8080"
]
, [ "# Start GraphQL Engine with multiple domains for CORS (https://example.com, http://localhost:3000 and https://*.foo.bar.com)"
, "graphql-engine --database-url <database-url> serve --cors-domain \"https://example.com, https://*.foo.bar.com, http://localhost:3000\""
]
, [ "# Start GraphQL Engine with Authentication Webhook (GET)"
, "graphql-engine --database-url <database-url> serve --access-key <secretaccesskey>"
<> " --auth-hook https://mywebhook.com/get"
@ -324,7 +323,7 @@ serveCmdFooter =
envVarDoc = mkEnvVarDoc $ envVars <> eventEnvs
envVars =
[ servePortEnv, serveHostEnv, pgStripesEnv, pgConnsEnv, pgTimeoutEnv
, txIsoEnv, accessKeyEnv, authHookEnv , authHookModeEnv
, pgUsePrepareEnv, txIsoEnv, accessKeyEnv, authHookEnv , authHookModeEnv
, jwtSecretEnv , unAuthRoleEnv, corsDomainEnv , enableConsoleEnv
, enableTelemetryEnv
]
@ -370,7 +369,7 @@ pgTimeoutEnv =
pgUsePrepareEnv :: (String, String)
pgUsePrepareEnv =
( "HASURA_GRAPHQL_USE_PREPARED_STATEMENTS"
, "Use prepared statements for queries (default: True)"
, "Use prepared statements for queries (default: true)"
)
txIsoEnv :: (String, String)
@ -388,13 +387,13 @@ accessKeyEnv =
authHookEnv :: (String, String)
authHookEnv =
( "HASURA_GRAPHQL_AUTH_HOOK"
, "The authentication webhook, required to authenticate requests"
, "URL of the authorization webhook required to authorize requests"
)
authHookModeEnv :: (String, String)
authHookModeEnv =
( "HASURA_GRAPHQL_AUTH_HOOK_MODE"
, "The authentication webhook mode (default: GET)"
, "HTTP method to use for authorization webhook (default: GET)"
)
jwtSecretEnv :: (String, String)
@ -413,7 +412,8 @@ unAuthRoleEnv =
corsDomainEnv :: (String, String)
corsDomainEnv =
( "HASURA_GRAPHQL_CORS_DOMAIN"
, "The domain, including scheme and port, to allow CORS for"
, "CSV of list of domains, excluding scheme (http/https) and including port, "
++ "to allow CORS for. Wildcard domains are allowed. See docs for details."
)
enableConsoleEnv :: (String, String)
@ -436,24 +436,24 @@ parseRawConnInfo =
where
host = optional $
strOption ( long "host" <>
metavar "HOST" <>
metavar "<HOST>" <>
help "Postgres server host" )
port = optional $
option auto ( long "port" <>
short 'p' <>
metavar "PORT" <>
metavar "<PORT>" <>
help "Postgres server port" )
user = optional $
strOption ( long "user" <>
short 'u' <>
metavar "USER" <>
metavar "<USER>" <>
help "Database user name" )
password =
strOption ( long "password" <>
metavar "PASSWORD" <>
metavar "<PASSWORD>" <>
value "" <>
help "Password of the user"
)
@ -461,14 +461,14 @@ parseRawConnInfo =
dbUrl = optional $
strOption
( long "database-url" <>
metavar "DATABASE-URL" <>
metavar "<DATABASE-URL>" <>
help (snd databaseUrlEnv)
)
dbName = optional $
strOption ( long "dbname" <>
short 'd' <>
metavar "NAME" <>
metavar "<DBNAME>" <>
help "Database name to connect to"
)
@ -497,7 +497,7 @@ parseTxIsolation = optional $
option (eitherReader readIsoLevel)
( long "tx-iso" <>
short 'i' <>
metavar "TXISO" <>
metavar "<TXISO>" <>
help (snd txIsoEnv)
)
@ -509,7 +509,7 @@ parseConnParams =
option auto
( long "stripes" <>
short 's' <>
metavar "NO OF STRIPES" <>
metavar "<NO OF STRIPES>" <>
help (snd pgStripesEnv)
)
@ -517,20 +517,20 @@ parseConnParams =
option auto
( long "connections" <>
short 'c' <>
metavar "NO OF CONNS" <>
metavar "<NO OF CONNS>" <>
help (snd pgConnsEnv)
)
timeout = optional $
option auto
( long "timeout" <>
metavar "SECONDS" <>
metavar "<SECONDS>" <>
help (snd pgTimeoutEnv)
)
allowPrepare = optional $
option (eitherReader parseStrAsBool)
( long "use-prepared-statements" <>
metavar "USE PREPARED STATEMENTS" <>
metavar "<true|false>" <>
help (snd pgUsePrepareEnv)
)
@ -538,13 +538,13 @@ parseServerPort :: Parser (Maybe Int)
parseServerPort = optional $
option auto
( long "server-port" <>
metavar "PORT" <>
metavar "<PORT>" <>
help (snd servePortEnv)
)
parseServerHost :: Parser (Maybe HostPreference)
parseServerHost = optional $ strOption ( long "server-host" <>
metavar "HOST" <>
metavar "<HOST>" <>
help "Host on which graphql-engine will listen (default: *)"
)
@ -552,7 +552,7 @@ parseAccessKey :: Parser (Maybe AccessKey)
parseAccessKey =
optional $ AccessKey <$>
strOption ( long "access-key" <>
metavar "SECRET ACCESS KEY" <>
metavar "<SECRET ACCESS KEY>" <>
help (snd accessKeyEnv)
)
@ -569,13 +569,13 @@ parseWebHook =
where
url = optional $
strOption ( long "auth-hook" <>
metavar "AUTHENTICATION WEB HOOK" <>
metavar "<WEB HOOK URL>" <>
help (snd authHookEnv)
)
urlType = optional $
option (eitherReader readHookType)
( long "auth-hook-mode" <>
metavar "GET|POST" <>
metavar "<GET|POST>" <>
help (snd authHookModeEnv)
)
@ -584,7 +584,7 @@ parseJwtSecret :: Parser (Maybe Text)
parseJwtSecret =
optional $ strOption
( long "jwt-secret" <>
metavar "JWK" <>
metavar "<JSON CONFIG>" <>
help (snd jwtSecretEnv)
)
@ -596,26 +596,28 @@ jwtSecretHelp = "The JSON containing type and the JWK used for verifying. e.g: "
parseUnAuthRole :: Parser (Maybe RoleName)
parseUnAuthRole = optional $
RoleName <$> strOption ( long "unauthorized-role" <>
metavar "UNAUTHORIZED ROLE" <>
metavar "<ROLE>" <>
help (snd unAuthRoleEnv)
)
parseCorsConfig :: Parser RawCorsConfig
parseCorsConfig =
CorsConfigG <$> corsDomain <*> disableCors
parseCorsConfig :: Parser (Maybe CorsConfig)
parseCorsConfig = mapCC <$> disableCors <*> corsDomain
where
corsDomain =
optional (strOption
( long "cors-domain" <>
metavar "CORS DOMAIN" <>
help (snd corsDomainEnv)
)
)
corsDomain = optional $
option (eitherReader readCorsDomains)
( long "cors-domain" <>
metavar "<DOMAINS>" <>
help (snd corsDomainEnv)
)
disableCors =
switch ( long "disable-cors" <>
help "Disable CORS handling"
help "Disable CORS. Do not send any CORS headers on any request"
)
mapCC isDisabled domains =
bool domains (Just CCDisabled) isDisabled
parseEnableConsole :: Parser Bool
parseEnableConsole =
switch ( long "enable-console" <>
@ -649,8 +651,7 @@ serveOptsToLog so =
, "auth_hook" J..= (ahUrl <$> soAuthHook so)
, "auth_hook_mode" J..= (show . ahType <$> soAuthHook so)
, "unauth_role" J..= soUnAuthRole so
, "cors_domain" J..= (ccDomain . soCorsConfig) so
, "cors_disabled" J..= (ccDisabled . soCorsConfig) so
, "cors_config" J..= soCorsConfig so
, "enable_console" J..= soEnableConsole so
, "enable_telemetry" J..= soEnableTelemetry so
, "use_prepared_statements" J..= (Q.cpAllowPrepare . soConnParams) so

View File

@ -3,41 +3,40 @@ module Hasura.Server.Middleware where
import Data.Maybe (fromMaybe)
import Network.Wai
import Control.Applicative
import Hasura.Prelude
import Hasura.Server.Cors
import Hasura.Server.Logging (getRequestHeader)
import Hasura.Server.Utils
import qualified Data.ByteString as B
import qualified Data.CaseInsensitive as CI
import qualified Data.Text as T
import qualified Data.HashSet as Set
import qualified Data.Text.Encoding as TE
import qualified Network.HTTP.Types as H
data CorsPolicy
= CorsPolicy
{ cpDomain :: !T.Text
, cpMethods :: ![T.Text]
, cpMaxAge :: !Int
} deriving (Show, Eq)
mkDefaultCorsPolicy :: T.Text -> CorsPolicy
mkDefaultCorsPolicy domain =
CorsPolicy
{ cpDomain = domain
, cpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
, cpMaxAge = 1728000
}
corsMiddleware :: CorsPolicy -> Middleware
corsMiddleware policy app req sendResp =
maybe (app req sendResp) handleCors $ getRequestHeader "Origin" req
where
handleCors origin
| cpDomain policy /= "*" && origin /= TE.encodeUtf8 (cpDomain policy) = app req sendResp
| otherwise =
case requestMethod req of
"OPTIONS" -> sendResp $ respondPreFlight origin
_ -> app req $ sendResp . injectCorsHeaders origin
handleCors origin = case cpConfig policy of
CCDisabled -> app req sendResp
CCAllowAll -> sendCors origin
CCAllowedOrigins ds
-- if the origin is in our cors domains, send cors headers
| bsToTxt origin `elem` dmFqdns ds -> sendCors origin
-- if current origin is part of wildcard domain list, send cors
| inWildcardList ds (bsToTxt origin) -> sendCors origin
-- otherwise don't send cors headers
| otherwise -> app req sendResp
sendCors :: B.ByteString -> IO ResponseReceived
sendCors origin =
case requestMethod req of
"OPTIONS" -> sendResp $ respondPreFlight origin
_ -> app req $ sendResp . injectCorsHeaders origin
respondPreFlight :: B.ByteString -> Response
respondPreFlight origin =
@ -67,3 +66,7 @@ corsMiddleware policy app req sendResp =
setHeaders hdrs = mapResponseHeaders (\h -> mkRespHdrs hdrs ++ h)
mkRespHdrs = map (\(k,v) -> (CI.mk k, v))
inWildcardList :: Domains -> Text -> Bool
inWildcardList (Domains _ wildcards) origin =
either (const False) (`Set.member` wildcards) $ parseOrigin origin

View File

@ -126,3 +126,8 @@ matchRegex regex caseSensitive src =
}
execOption = TDFA.defaultExecOpt {TDFA.captureGroups = False}
compiledRegexE = TDFA.compile compOpt execOption regex
fmapL :: (a -> a') -> Either a b -> Either a' b
fmapL fn (Left e) = Left (fn e)
fmapL _ (Right x) = pure x

View File

@ -27,6 +27,12 @@ def pytest_addoption(parser):
"--hge-jwt-conf", metavar="HGE_JWT_CONF", help="The JWT conf", required=False
)
parser.addoption(
"--test-cors", action="store_true",
required=False,
help="Run testcases for CORS configuration"
)
@pytest.fixture(scope='session')
def hge_ctx(request):
@ -38,6 +44,7 @@ def hge_ctx(request):
webhook_insecure = request.config.getoption('--test-webhook-insecure')
hge_jwt_key_file = request.config.getoption('--hge-jwt-key-file')
hge_jwt_conf = request.config.getoption('--hge-jwt-conf')
test_cors = request.config.getoption('--test-cors')
try:
hge_ctx = HGECtx(
hge_url=hge_url,
@ -50,6 +57,7 @@ def hge_ctx(request):
)
except HGECtxError as e:
pytest.exit(str(e))
yield hge_ctx # provide the fixture value
print("teardown hge_ctx")
hge_ctx.teardown()

View File

@ -0,0 +1,54 @@
import pytest
if not pytest.config.getoption("--test-cors"):
pytest.skip("--test-cors flag is missing, skipping tests", allow_module_level=True)
def url(hge_ctx):
return hge_ctx.hge_url + '/v1/version'
class TestCors():
"""
currently assumes the following is set:
HASURA_GRAPHQL_CORS_DOMAIN="http://*.localhost, http://localhost:3000, https://*.foo.bar.com"
"""
def assert_cors_headers(self, origin, resp):
headers = resp.headers
assert 'Access-Control-Allow-Origin' in headers
assert headers['Access-Control-Allow-Origin'] == origin
assert 'Access-Control-Allow-Credentials' in headers
assert headers['Access-Control-Allow-Credentials'] == 'true'
assert 'Access-Control-Allow-Methods' in headers
assert headers['Access-Control-Allow-Methods'] == 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
def test_cors_foo_bar_top_domain(self, hge_ctx):
origin = 'https://foo.bar.com'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
with pytest.raises(AssertionError):
self.assert_cors_headers(origin, resp)
def test_cors_foo_bar_sub_domain(self, hge_ctx):
origin = 'https://app.foo.bar.com'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
self.assert_cors_headers(origin, resp)
def test_cors_foo_bar_sub_sub_domain_fails(self, hge_ctx):
origin = 'https://inst1.app.foo.bar.com'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
with pytest.raises(AssertionError):
self.assert_cors_headers(origin, resp)
def test_cors_localhost_domain_w_port(self, hge_ctx):
origin = 'http://localhost:3000'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
self.assert_cors_headers(origin, resp)
def test_cors_localhost_domain(self, hge_ctx):
origin = 'http://app.localhost'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
self.assert_cors_headers(origin, resp)
def test_cors_wrong_domain(self, hge_ctx):
origin = 'https://example.com'
resp = hge_ctx.http.get(url(hge_ctx), headers={'Origin': origin})
assert 'Access-Control-Allow-Origin' not in resp.headers