From f3c603ae45d6ebd634839568a664f06319895a80 Mon Sep 17 00:00:00 2001 From: Taimoor Zaeem Date: Tue, 10 Oct 2023 22:13:53 +0500 Subject: [PATCH] feat: add config to specify CORS origins --- CHANGELOG.md | 4 ++ src/PostgREST/App.hs | 2 +- src/PostgREST/CLI.hs | 3 ++ src/PostgREST/Config.hs | 5 ++- src/PostgREST/Cors.hs | 15 ++++--- test/io/configs/expected/aliases.config | 1 + .../configs/expected/boolean-numeric.config | 1 + .../io/configs/expected/boolean-string.config | 1 + test/io/configs/expected/defaults.config | 1 + ...efaults-with-db-other-authenticator.config | 1 + .../expected/no-defaults-with-db.config | 1 + test/io/configs/expected/no-defaults.config | 1 + test/io/configs/expected/types.config | 1 + test/io/configs/no-defaults-env.yaml | 1 + test/io/configs/no-defaults.config | 1 + test/io/test_io.py | 44 +++++++++++++++++++ test/spec/SpecHelper.hs | 1 + 17 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7820359fc1..892ee11223c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #2771, Add `Server-Timing` header with JWT duration - @taimoorzaeem - #2698, Add config `jwt-cache-max-lifetime` and implement JWT caching - @taimoorzaeem - #2943, Add `handling=strict/lenient` for Prefer header - @taimoorzaeem + - #2441, Add config `cors-allowed-origins` to specify CORS origins - @taimoorzaeem + +### Fixed + - #2824, Fix range request with 0 rows and 0 offset return status 416 - @strengthless ## [11.2.1] - 2023-10-03 diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index aed65e88932..bb1c073474e 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -106,7 +106,7 @@ serverSettings AppConfig{..} = postgrest :: AppConfig -> AppState.AppState -> IO () -> Wai.Application postgrest conf appState connWorker = traceHeaderMiddleware conf . - Cors.middleware . + Cors.middleware (configCorsAllowedOrigins conf) . Auth.middleware appState . Logger.middleware (configLogLevel conf) $ -- fromJust can be used, because the auth middleware will **always** add diff --git a/src/PostgREST/CLI.hs b/src/PostgREST/CLI.hs index 2bbd9072789..a91604acd2a 100644 --- a/src/PostgREST/CLI.hs +++ b/src/PostgREST/CLI.hs @@ -126,6 +126,9 @@ exampleConfigFile = [str|## Admin server used for checks. It's disabled by default unless a port is specified. |# admin-server-port = 3001 | + |## Configurable CORS origins + |# cors-allowed-origins = "*" + | |## The database role to use when no client authentication is provided |# db-anon-role = "anon" | diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 73f28481457..921e82f37d4 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -70,6 +70,7 @@ import Protolude hiding (Proxy, toList) data AppConfig = AppConfig { configAppSettings :: [(Text, Text)] + , configCorsAllowedOrigins :: Text , configDbAnonRole :: Maybe BS.ByteString , configDbChannel :: Text , configDbChannelEnabled :: Bool @@ -139,7 +140,8 @@ toText conf = where -- apply conf to all pgrst settings pgrstSettings = (\(k, v) -> (k, v conf)) <$> - [("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole) + [("cors-allowed-origins", q . configCorsAllowedOrigins) + ,("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole) ,("db-channel", q . configDbChannel) ,("db-channel-enabled", T.toLower . show . configDbChannelEnabled) ,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath) @@ -233,6 +235,7 @@ parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> Rol parser optPath env dbSettings roleSettings roleIsolationLvl = AppConfig <$> parseAppSettings "app.settings" + <*> (fromMaybe "*" <$> optString "cors-allowed-origins") <*> (fmap encodeUtf8 <$> optString "db-anon-role") <*> (fromMaybe "pgrst" <$> optString "db-channel") <*> (fromMaybe True <$> optBool "db-channel-enabled") diff --git a/src/PostgREST/Cors.hs b/src/PostgREST/Cors.hs index df38f1d9071..5b1b7018d84 100644 --- a/src/PostgREST/Cors.hs +++ b/src/PostgREST/Cors.hs @@ -6,6 +6,8 @@ module PostgREST.Cors (middleware) where import qualified Data.ByteString.Char8 as BS import qualified Data.CaseInsensitive as CI +import qualified Data.Text as T +import qualified Data.Text.Encoding as T import qualified Network.Wai as Wai import qualified Network.Wai.Middleware.Cors as Wai @@ -13,15 +15,15 @@ import Data.List (lookup) import Protolude -middleware :: Wai.Middleware -middleware = Wai.cors corsPolicy +middleware :: Text -> Wai.Middleware +middleware corsAllowedOrigins = Wai.cors $ corsPolicy corsAllowedOrigins -- | CORS policy to be used in by Wai Cors middleware -corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy -corsPolicy req = case lookup "origin" headers of +corsPolicy :: Text -> Wai.Request -> Maybe Wai.CorsResourcePolicy +corsPolicy corsAllowedOrigins req = case lookup "origin" headers of Just origin -> Just Wai.CorsResourcePolicy - { Wai.corsOrigins = Just ([origin], True) + { Wai.corsOrigins = if checkOrigin origin then Just ([origin],True) else Nothing , Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] , Wai.corsRequestHeaders = "Authorization" : accHeaders , Wai.corsExposedHeaders = Just @@ -34,6 +36,9 @@ corsPolicy req = case lookup "origin" headers of } Nothing -> Nothing where + checkOrigin origin = + corsAllowedOrigins == "*" || + T.decodeUtf8 origin `elem` T.splitOn ", " corsAllowedOrigins headers = Wai.requestHeaders req accHeaders = case lookup "access-control-request-headers" headers of Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs diff --git a/test/io/configs/expected/aliases.config b/test/io/configs/expected/aliases.config index 3e1df8855e2..8a6147c872e 100644 --- a/test/io/configs/expected/aliases.config +++ b/test/io/configs/expected/aliases.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "*" db-anon-role = "" db-channel = "pgrst" db-channel-enabled = true diff --git a/test/io/configs/expected/boolean-numeric.config b/test/io/configs/expected/boolean-numeric.config index 1b86f251f6b..6edf9ea71c0 100644 --- a/test/io/configs/expected/boolean-numeric.config +++ b/test/io/configs/expected/boolean-numeric.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "*" db-anon-role = "" db-channel = "pgrst" db-channel-enabled = true diff --git a/test/io/configs/expected/boolean-string.config b/test/io/configs/expected/boolean-string.config index 1b86f251f6b..6edf9ea71c0 100644 --- a/test/io/configs/expected/boolean-string.config +++ b/test/io/configs/expected/boolean-string.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "*" db-anon-role = "" db-channel = "pgrst" db-channel-enabled = true diff --git a/test/io/configs/expected/defaults.config b/test/io/configs/expected/defaults.config index d4f6a9624e1..94e2c11d9bb 100644 --- a/test/io/configs/expected/defaults.config +++ b/test/io/configs/expected/defaults.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "*" db-anon-role = "" db-channel = "pgrst" db-channel-enabled = true diff --git a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config index 856a109a07a..95b5acbae11 100644 --- a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config +++ b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "http://example.com" db-anon-role = "pre_config_role" db-channel = "postgrest" db-channel-enabled = false diff --git a/test/io/configs/expected/no-defaults-with-db.config b/test/io/configs/expected/no-defaults-with-db.config index 9cab547f6ea..04ca71c692d 100644 --- a/test/io/configs/expected/no-defaults-with-db.config +++ b/test/io/configs/expected/no-defaults-with-db.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "http://example.com" db-anon-role = "anonymous" db-channel = "postgrest" db-channel-enabled = false diff --git a/test/io/configs/expected/no-defaults.config b/test/io/configs/expected/no-defaults.config index 1e84858d06e..cfd81c2e2a8 100644 --- a/test/io/configs/expected/no-defaults.config +++ b/test/io/configs/expected/no-defaults.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "http://example.com" db-anon-role = "root" db-channel = "postgrest" db-channel-enabled = false diff --git a/test/io/configs/expected/types.config b/test/io/configs/expected/types.config index 40bda26d5c0..8f75ad5e6b9 100644 --- a/test/io/configs/expected/types.config +++ b/test/io/configs/expected/types.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "*" db-anon-role = "" db-channel = "pgrst" db-channel-enabled = true diff --git a/test/io/configs/no-defaults-env.yaml b/test/io/configs/no-defaults-env.yaml index d3cd013ee49..42f19ab599c 100644 --- a/test/io/configs/no-defaults-env.yaml +++ b/test/io/configs/no-defaults-env.yaml @@ -1,5 +1,6 @@ PGRST_APP_SETTINGS_test2: test PGRST_APP_SETTINGS_test: test +PGRST_CORS_ALLOWED_ORIGINS: "http://example.com" PGRST_DB_ANON_ROLE: root PGRST_DB_CHANNEL: postgrest PGRST_DB_CHANNEL_ENABLED: false diff --git a/test/io/configs/no-defaults.config b/test/io/configs/no-defaults.config index 5488b065f96..60315196932 100644 --- a/test/io/configs/no-defaults.config +++ b/test/io/configs/no-defaults.config @@ -1,3 +1,4 @@ +cors-allowed-origins = "http://example.com" db-anon-role = "root" db-channel = "postgrest" db-channel-enabled = false diff --git a/test/io/test_io.py b/test/io/test_io.py index 72cc17eb29a..93c5db168f5 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -1214,3 +1214,47 @@ def test_jwt_cache_with_no_exp_claim(defaultenv): # their difference should be atleast 300, implying # that JWT Caching is working as expected assert (first_dur - second_dur) > 300.0 + + +def test_preflight_request_with_cors_allowed_origin_config(defaultenv): + "OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin" + + env = { + **defaultenv, + "PGRST_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com", + } + + headers = { + "Accept": "*/*", + "Origin": "http://example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + } + + with run(env=env) as postgrest: + response = postgrest.session.options("/items", headers=headers) + assert ( + response.headers["Access-Control-Allow-Origin"] == "http://example.com" + and response.headers["Access-Control-Allow-Credentials"] == "true" + ) + + +def test_options_no_preflight_request_with_cors_allowed_origin_config(defaultenv): + "OPTIONS no preflight request should return Access-Control-Allow-Origin equal to origin" + + env = { + **defaultenv, + "PGRST_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com", + } + + headers = { + "Accept": "*/*", + "Origin": "http://example.com", + } + + with run(env=env) as postgrest: + response = postgrest.session.options("/items", headers=headers) + assert ( + response.headers["Access-Control-Allow-Origin"] + == "*" # not sure, maybe "http://example.com" ? + ) diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 7bee04a95b8..96432cf72c4 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -93,6 +93,7 @@ baseCfg :: AppConfig baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in AppConfig { configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ] + , configCorsAllowedOrigins = "*" , configDbAnonRole = Just "postgrest_test_anonymous" , configDbChannel = mempty , configDbChannelEnabled = True