feat: log error message when JWT secret is less than 32 characters long
breaking change: PostgREST now fails to start or reload the config when the JWT secret is less than 32 characters long.
This commit is contained in:
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- #3558, Add the `admin-server-host` config to set the host for the admin server - @develop7
|
- #3558, Add the `admin-server-host` config to set the host for the admin server - @develop7
|
||||||
|
- #3607, Log to stderr when the JWT secret is less than 32 characters long - @laurenceisla
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- #2052, Dropped support for PostgreSQL 10 - @wolfgangwalther
|
- #2052, Dropped support for PostgreSQL 10 - @wolfgangwalther
|
||||||
- #2052, Dropped support for PostgreSQL 11 - @wolfgangwalther
|
- #2052, Dropped support for PostgreSQL 11 - @wolfgangwalther
|
||||||
- #3508, PostgREST now fails to start when `server-port` and `admin-server-port` config options are the same - @develop7
|
- #3508, PostgREST now fails to start when `server-port` and `admin-server-port` config options are the same - @develop7
|
||||||
|
- #3607, PostgREST now fails to start when the JWT secret is less than 32 characters long - @laurenceisla
|
||||||
|
|
||||||
## [12.2.1] - 2024-06-27
|
## [12.2.1] - 2024-06-27
|
||||||
|
|
||||||
|
|||||||
+22
-13
@@ -220,18 +220,20 @@ readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do
|
|||||||
|
|
||||||
case C.runParser (parser optPath env dbSettings roleSettings roleIsolationLvl) =<< mapLeft show conf of
|
case C.runParser (parser optPath env dbSettings roleSettings roleIsolationLvl) =<< mapLeft show conf of
|
||||||
Left err ->
|
Left err ->
|
||||||
return . Left $ "Error in config: " <> err
|
return . Left $ "Error in config " <> err
|
||||||
Right config ->
|
Right parsedConfig ->
|
||||||
Right <$> decodeLoadFiles config
|
mapLeft show <$> decodeLoadFiles parsedConfig
|
||||||
where
|
where
|
||||||
-- Both C.ParseError and IOError are shown here
|
-- Both C.ParseError and IOError are shown here
|
||||||
loadConfig :: FilePath -> IO (Either SomeException C.Config)
|
loadConfig :: FilePath -> IO (Either SomeException C.Config)
|
||||||
loadConfig = try . C.load
|
loadConfig = try . C.load
|
||||||
|
|
||||||
decodeLoadFiles :: AppConfig -> IO AppConfig
|
decodeLoadFiles :: AppConfig -> IO (Either IOException AppConfig)
|
||||||
decodeLoadFiles parsedConfig =
|
decodeLoadFiles parsedConfig = try $
|
||||||
decodeJWKS <$>
|
decodeJWKS =<<
|
||||||
(decodeSecret =<< readSecretFile =<< readDbUriFile prevDbUri parsedConfig)
|
decodeSecret =<<
|
||||||
|
readSecretFile =<<
|
||||||
|
readDbUriFile prevDbUri parsedConfig
|
||||||
|
|
||||||
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
|
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
|
||||||
parser optPath env dbSettings roleSettings roleIsolationLvl =
|
parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||||
@@ -459,18 +461,25 @@ decodeSecret conf@AppConfig{..} =
|
|||||||
-- There are three ways to specify `jwt-secret`: text secret, JSON Web Key
|
-- There are three ways to specify `jwt-secret`: text secret, JSON Web Key
|
||||||
-- (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JwkSet
|
-- (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JwkSet
|
||||||
-- with one key and the last is converted as is.
|
-- with one key and the last is converted as is.
|
||||||
decodeJWKS :: AppConfig -> AppConfig
|
decodeJWKS :: AppConfig -> IO AppConfig
|
||||||
decodeJWKS conf =
|
decodeJWKS conf = do
|
||||||
conf { configJWKS = parseSecret <$> configJwtSecret conf }
|
jwks <- case configJwtSecret conf of
|
||||||
|
Just s -> either fail (pure . Just) $ parseSecret s
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
return $ conf { configJWKS = jwks }
|
||||||
|
|
||||||
parseSecret :: ByteString -> JwkSet
|
parseSecret :: ByteString -> Either [Char] JwkSet
|
||||||
parseSecret bytes =
|
parseSecret bytes =
|
||||||
fromMaybe (maybe secret (\jwk' -> JWT.JwkSet [jwk']) maybeJWK)
|
case maybeJWKSet of
|
||||||
maybeJWKSet
|
Just jwk -> Right jwk
|
||||||
|
Nothing -> maybe validateSecret (\jwk' -> Right $ JWT.JwkSet [jwk']) maybeJWK
|
||||||
where
|
where
|
||||||
maybeJWKSet = JSON.decodeStrict bytes :: Maybe JwkSet
|
maybeJWKSet = JSON.decodeStrict bytes :: Maybe JwkSet
|
||||||
maybeJWK = JSON.decodeStrict bytes :: Maybe Jwk
|
maybeJWK = JSON.decodeStrict bytes :: Maybe Jwk
|
||||||
secret = JWT.JwkSet [JWT.SymmetricJwk bytes Nothing (Just JWT.Sig) (Just $ JWT.Signed JWT.HS256)]
|
secret = JWT.JwkSet [JWT.SymmetricJwk bytes Nothing (Just JWT.Sig) (Just $ JWT.Signed JWT.HS256)]
|
||||||
|
validateSecret
|
||||||
|
| BS.length bytes < 32 = Left "The JWT secret must be at least 32 characters long."
|
||||||
|
| otherwise = Right secret
|
||||||
|
|
||||||
-- | Read database uri from a separate file if `db-uri` is a filepath.
|
-- | Read database uri from a separate file if `db-uri` is a filepath.
|
||||||
readDbUriFile :: Maybe Text -> AppConfig -> IO AppConfig
|
readDbUriFile :: Maybe Text -> AppConfig -> IO AppConfig
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ db-tx-end = "rollback-allow-override"
|
|||||||
db-uri = "tmp_db"
|
db-uri = "tmp_db"
|
||||||
jwt-aud = "https://postgrest.org"
|
jwt-aud = "https://postgrest.org"
|
||||||
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
||||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ="
|
||||||
jwt-secret-is-base64 = true
|
jwt-secret-is-base64 = true
|
||||||
jwt-cache-max-lifetime = 86400
|
jwt-cache-max-lifetime = 86400
|
||||||
log-level = "info"
|
log-level = "info"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ PGRST_DB_URI: tmp_db
|
|||||||
PGRST_DB_USE_LEGACY_GUCS: false
|
PGRST_DB_USE_LEGACY_GUCS: false
|
||||||
PGRST_JWT_AUD: 'https://postgrest.org'
|
PGRST_JWT_AUD: 'https://postgrest.org'
|
||||||
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
||||||
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
|
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ=
|
||||||
PGRST_JWT_SECRET_IS_BASE64: true
|
PGRST_JWT_SECRET_IS_BASE64: true
|
||||||
PGRST_JWT_CACHE_MAX_LIFETIME: 86400
|
PGRST_JWT_CACHE_MAX_LIFETIME: 86400
|
||||||
PGRST_LOG_LEVEL: info
|
PGRST_LOG_LEVEL: info
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ db-tx-end = "rollback-allow-override"
|
|||||||
db-uri = "tmp_db"
|
db-uri = "tmp_db"
|
||||||
jwt-aud = "https://postgrest.org"
|
jwt-aud = "https://postgrest.org"
|
||||||
jwt-role-claim-key = ".user[0].\"real-role\""
|
jwt-role-claim-key = ".user[0].\"real-role\""
|
||||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ="
|
||||||
jwt-secret-is-base64 = true
|
jwt-secret-is-base64 = true
|
||||||
jwt-cache-max-lifetime = 86400
|
jwt-cache-max-lifetime = 86400
|
||||||
log-level = "info"
|
log-level = "info"
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ def test_read_secret_from_stdin_dbconfig(defaultenv):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_min_length(defaultenv):
|
||||||
|
"Should log error and not load the config when the secret is shorter than the minimum admitted length"
|
||||||
|
|
||||||
|
env = {**defaultenv, "PGRST_JWT_SECRET": "short_secret"}
|
||||||
|
|
||||||
|
with run(env=env, no_startup_stdout=False, wait_for_readiness=False) as postgrest:
|
||||||
|
exitCode = wait_until_exit(postgrest)
|
||||||
|
assert exitCode == 1
|
||||||
|
|
||||||
|
output = postgrest.read_stdout(nlines=1)
|
||||||
|
assert "The JWT secret must be at least 32 characters long." in output[0]
|
||||||
|
|
||||||
|
|
||||||
def test_jwt_errors(defaultenv):
|
def test_jwt_errors(defaultenv):
|
||||||
"invalid JWT should throw error"
|
"invalid JWT should throw error"
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -108,7 +108,7 @@ validateOpenApiResponse headers = do
|
|||||||
|
|
||||||
|
|
||||||
baseCfg :: AppConfig
|
baseCfg :: AppConfig
|
||||||
baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
||||||
AppConfig {
|
AppConfig {
|
||||||
configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
|
configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
|
||||||
, configDbAggregates = False
|
, configDbAggregates = False
|
||||||
@@ -132,10 +132,10 @@ baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
|||||||
, configDbPreConfig = Nothing
|
, configDbPreConfig = Nothing
|
||||||
, configDbUri = "postgresql://"
|
, configDbUri = "postgresql://"
|
||||||
, configFilePath = Nothing
|
, configFilePath = Nothing
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = rightToMaybe $ parseSecret secret
|
||||||
, configJwtAudience = Nothing
|
, configJwtAudience = Nothing
|
||||||
, configJwtRoleClaimKey = [JSPKey "role"]
|
, configJwtRoleClaimKey = [JSPKey "role"]
|
||||||
, configJwtSecret = secret
|
, configJwtSecret = Just secret
|
||||||
, configJwtSecretIsBase64 = False
|
, configJwtSecretIsBase64 = False
|
||||||
, configJwtCacheMaxLifetime = 0
|
, configJwtCacheMaxLifetime = 0
|
||||||
, configLogLevel = LogCrit
|
, configLogLevel = LogCrit
|
||||||
@@ -196,35 +196,35 @@ testPlanEnabledCfg = baseCfg { configDbPlanEnabled = True }
|
|||||||
|
|
||||||
testCfgBinaryJWT :: AppConfig
|
testCfgBinaryJWT :: AppConfig
|
||||||
testCfgBinaryJWT =
|
testCfgBinaryJWT =
|
||||||
let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
|
let secret = B64.decodeLenient "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
|
||||||
baseCfg {
|
baseCfg {
|
||||||
configJwtSecret = secret
|
configJwtSecret = Just secret
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = rightToMaybe $ parseSecret secret
|
||||||
}
|
}
|
||||||
|
|
||||||
testCfgAudienceJWT :: AppConfig
|
testCfgAudienceJWT :: AppConfig
|
||||||
testCfgAudienceJWT =
|
testCfgAudienceJWT =
|
||||||
let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
|
let secret = B64.decodeLenient "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
|
||||||
baseCfg {
|
baseCfg {
|
||||||
configJwtSecret = secret
|
configJwtSecret = Just secret
|
||||||
, configJwtAudience = Just "youraudience"
|
, configJwtAudience = Just "youraudience"
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = rightToMaybe $ parseSecret secret
|
||||||
}
|
}
|
||||||
|
|
||||||
testCfgAsymJWK :: AppConfig
|
testCfgAsymJWK :: AppConfig
|
||||||
testCfgAsymJWK =
|
testCfgAsymJWK =
|
||||||
let secret = Just $ encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
|
let secret = encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
|
||||||
in baseCfg {
|
in baseCfg {
|
||||||
configJwtSecret = secret
|
configJwtSecret = Just secret
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = rightToMaybe $ parseSecret secret
|
||||||
}
|
}
|
||||||
|
|
||||||
testCfgAsymJWKSet :: AppConfig
|
testCfgAsymJWKSet :: AppConfig
|
||||||
testCfgAsymJWKSet =
|
testCfgAsymJWKSet =
|
||||||
let secret = Just $ encodeUtf8 [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
|
let secret = encodeUtf8 [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
|
||||||
in baseCfg {
|
in baseCfg {
|
||||||
configJwtSecret = secret
|
configJwtSecret = Just secret
|
||||||
, configJWKS = parseSecret <$> secret
|
, configJWKS = rightToMaybe $ parseSecret secret
|
||||||
}
|
}
|
||||||
|
|
||||||
testNonexistentSchemaCfg :: AppConfig
|
testNonexistentSchemaCfg :: AppConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user