diff --git a/CHANGELOG.md b/CHANGELOG.md index af48a433307..e3550d1cf96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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 diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index aed65e88932..21adc1cbd47 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -216,16 +216,17 @@ handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@A return $ pgrstResponse pgrst (ActionInfo, TargetIdent identifier) -> do - pgrst <- liftEither $ Response.infoIdentResponse identifier sCache + pgrst <- liftEither $ Response.infoIdentResponse identifier sCache (configCorsAllowedOrigins conf) return $ pgrstResponse pgrst (ActionInfo, TargetProc identifier _) -> do cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead - pgrst <- liftEither $ Response.infoProcResponse (Plan.crProc cPlan) + pgrst <- liftEither $ Response.infoProcResponse (Plan.crProc cPlan) (configCorsAllowedOrigins conf) + return $ pgrstResponse pgrst (ActionInfo, TargetDefaultSpec _) -> do - pgrst <- liftEither Response.infoRootResponse + pgrst <- liftEither $ Response.infoRootResponse (configCorsAllowedOrigins conf) return $ pgrstResponse pgrst _ -> 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/Response.hs b/src/PostgREST/Response.hs index 24b3f548195..97d220ab209 100644 --- a/src/PostgREST/Response.hs +++ b/src/PostgREST/Response.hs @@ -23,6 +23,7 @@ import qualified Data.Aeson as JSON import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Lazy as LBS import qualified Data.HashMap.Strict as HM +import qualified Data.Text.Encoding as T import Data.Text.Read (decimal) import qualified Network.HTTP.Types.Header as HTTP import qualified Network.HTTP.Types.Status as HTTP @@ -209,10 +210,10 @@ deleteResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Pre RSPlan plan -> Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan -infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Either Error.Error PgrstResponse -infoIdentResponse identifier sCache = do +infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Text -> Either Error.Error PgrstResponse +infoIdentResponse identifier sCache corsAllowedOrigins = do case HM.lookup identifier (dbTables sCache) of - Just tbl -> respondInfo $ allowH tbl + Just tbl -> respondInfo corsAllowedOrigins $ allowH tbl Nothing -> Left $ Error.ApiRequestError ApiRequestTypes.NotFound where allowH table = @@ -224,17 +225,18 @@ infoIdentResponse identifier sCache = do ["PATCH" | tableUpdatable table] ++ ["DELETE" | tableDeletable table] -infoProcResponse :: Routine -> Either Error.Error PgrstResponse -infoProcResponse proc | pdVolatility proc == Volatile = respondInfo "OPTIONS,POST" - | otherwise = respondInfo "OPTIONS,GET,HEAD,POST" +infoProcResponse :: Routine -> Text -> Either Error.Error PgrstResponse +infoProcResponse proc corsAllowedOrigins + | pdVolatility proc == Volatile = respondInfo corsAllowedOrigins "OPTIONS,POST" + | otherwise = respondInfo corsAllowedOrigins "OPTIONS,GET,HEAD,POST" -infoRootResponse :: Either Error.Error PgrstResponse -infoRootResponse = respondInfo "OPTIONS,GET,HEAD" +infoRootResponse :: Text -> Either Error.Error PgrstResponse +infoRootResponse corsAllowedOrigins = respondInfo corsAllowedOrigins "OPTIONS,GET,HEAD" -respondInfo :: ByteString -> Either Error.Error PgrstResponse -respondInfo allowHeader = - let allOrigins = ("Access-Control-Allow-Origin", "*") in - Right $ PgrstResponse HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty +respondInfo :: Text -> ByteString -> Either Error.Error PgrstResponse +respondInfo corsAllowedOrigins allowHeader = + let allowedOrigins = ("Access-Control-Allow-Origin", T.encodeUtf8 corsAllowedOrigins) in + Right $ PgrstResponse HTTP.status200 [allowedOrigins, (HTTP.hAllow, allowHeader)] mempty invokeResponse :: CallReadPlan -> InvokeMethod -> Routine -> ApiRequest -> ResultSet -> Maybe ServerTimingParams -> Either Error.Error PgrstResponse invokeResponse CallReadPlan{crMedia} invMethod proc ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} resultSet serverTimingParams = case resultSet of 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..21ab770d67e 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -1214,3 +1214,26 @@ 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_cors_allowed_origin_config_no_default(defaultenv): + "OPTIONS request should return Access-Control-Allow-Origin with config" + + env = { + **defaultenv, + "PGRST_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com", + } + + headers = { + "Accept": "*/*", + "Origin": "http://example.com", + "Access-Control-Allow-Method": "POST", + "Access-Control-Allow-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, http://example2.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