mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
fix(config): resolve signing secrets lazily so load order cannot split them
Found by a runtime probe against a locally built API: login returned 200 but the access token it issued was rejected on the next request, because the process was signing with one secret and verifying with another. apps/api/src/main.ts calls loadEnv() (which reads .env.local and friends) only after its imports have run, so @gauzy/config can be evaluated while JWT_SECRET is still unset. With the published literal as the fallback that was invisible: the early copy and any later copy both ended up on 'secretKey'. Once an unset secret became a per-process random value, the early copy generated one while a copy imported after loadEnv() read the configured value — so tokens signed by one never verified in the other, and every authenticated request 401'd. The four secrets are now getters on `environment` / `environment.prod` / `defaultConfiguration.authOptions`, so each is read at first use, after the env files are loaded. Adds the regression test for exactly that order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6853ceba3d
commit
21e0bfd649
@@ -91,10 +91,16 @@ export const defaultConfiguration: ApplicationPluginConfig = {
|
||||
User: []
|
||||
},
|
||||
authOptions: {
|
||||
// Same resolver as `environment`, so both surfaces agree on the per-process value (GHSA-39j7-x845-4w3c).
|
||||
expressSessionSecret: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'),
|
||||
// Same resolver as `environment`, so both surfaces agree on the per-process value, and read
|
||||
// lazily for the same reason: the API loads its env files after its imports have run, so an
|
||||
// eagerly resolved secret would be decided before `.env.local` exists (GHSA-39j7-x845-4w3c).
|
||||
get expressSessionSecret(): string {
|
||||
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy');
|
||||
},
|
||||
userPasswordBcryptSaltRounds: 12,
|
||||
jwtSecret: resolveSecret('JWT_SECRET', 'secretKey')
|
||||
get jwtSecret(): string {
|
||||
return resolveSecret('JWT_SECRET', 'secretKey');
|
||||
}
|
||||
},
|
||||
assetOptions: {
|
||||
assetPath: assetPath,
|
||||
|
||||
@@ -67,19 +67,30 @@ export const environment: IEnvironment = {
|
||||
LOG_LEVEL: 'debug'
|
||||
},
|
||||
|
||||
EXPRESS_SESSION_SECRET: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'), // Never a published literal outside DEMO (GHSA-39j7-x845-4w3c)
|
||||
// Getters, so the value is read when it is first used rather than when this module is imported:
|
||||
// the API loads its env files after its imports have run, so an eagerly read secret can be
|
||||
// decided before they are loaded (GHSA-39j7-x845-4w3c).
|
||||
get EXPRESS_SESSION_SECRET(): string {
|
||||
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'); // Never a published literal outside DEMO
|
||||
},
|
||||
USER_PASSWORD_BCRYPT_SALT_ROUNDS: 12,
|
||||
|
||||
JWT_SECRET: process.env.JWT_SECRET!, // Validated at startup — must be set in production
|
||||
get JWT_SECRET(): string {
|
||||
return process.env.JWT_SECRET!; // Validated at startup — must be set in production
|
||||
},
|
||||
JWT_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_TOKEN_EXPIRATION_TIME) || 86400 * 1, // default JWT token expire time (1 day)
|
||||
|
||||
JWT_REFRESH_TOKEN_SECRET: process.env.JWT_REFRESH_TOKEN_SECRET!, // Validated at startup — must be set in production
|
||||
get JWT_REFRESH_TOKEN_SECRET(): string {
|
||||
return process.env.JWT_REFRESH_TOKEN_SECRET!; // Validated at startup — must be set in production
|
||||
},
|
||||
JWT_REFRESH_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default JWT refresh token expire time (7 days)
|
||||
|
||||
/**
|
||||
* Email verification options
|
||||
*/
|
||||
JWT_VERIFICATION_TOKEN_SECRET: process.env.JWT_VERIFICATION_TOKEN_SECRET!, // Validated at startup — must be set in production
|
||||
get JWT_VERIFICATION_TOKEN_SECRET(): string {
|
||||
return process.env.JWT_VERIFICATION_TOKEN_SECRET!; // Validated at startup — must be set in production
|
||||
},
|
||||
JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_VERIFICATION_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default verification expire token time (7 days)
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,20 +32,35 @@ export const environment: IEnvironment = {
|
||||
* Token-signing and session secrets never fall back to a published literal outside DEMO: unset
|
||||
* means a random per-process value (and a refused boot in production). See resolveSecret()
|
||||
* (GHSA-39j7-x845-4w3c).
|
||||
*
|
||||
* They are GETTERS, so the value is resolved when it is first used rather than when this module
|
||||
* is imported. `apps/api/src/main.ts` calls `loadEnv()` (which reads `.env.local` and friends)
|
||||
* only AFTER its imports have run, so an eagerly resolved secret would be decided before those
|
||||
* files are loaded — this copy would generate a random value while a copy imported later saw the
|
||||
* configured one, and tokens signed by one would not verify in the other. With the published
|
||||
* literal that clash was invisible, because both copies ended up on the same literal.
|
||||
*/
|
||||
EXPRESS_SESSION_SECRET: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'),
|
||||
get EXPRESS_SESSION_SECRET(): string {
|
||||
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy');
|
||||
},
|
||||
USER_PASSWORD_BCRYPT_SALT_ROUNDS: 12,
|
||||
|
||||
JWT_SECRET: resolveSecret('JWT_SECRET', 'secretKey'),
|
||||
get JWT_SECRET(): string {
|
||||
return resolveSecret('JWT_SECRET', 'secretKey');
|
||||
},
|
||||
JWT_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_TOKEN_EXPIRATION_TIME) || 86400 * 1, // default JWT token expire time (1 day)
|
||||
|
||||
JWT_REFRESH_TOKEN_SECRET: resolveSecret('JWT_REFRESH_TOKEN_SECRET', 'refreshSecretKey'),
|
||||
get JWT_REFRESH_TOKEN_SECRET(): string {
|
||||
return resolveSecret('JWT_REFRESH_TOKEN_SECRET', 'refreshSecretKey');
|
||||
},
|
||||
JWT_REFRESH_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default JWT refresh token expire time (7 days)
|
||||
|
||||
/**
|
||||
* Email verification options
|
||||
*/
|
||||
JWT_VERIFICATION_TOKEN_SECRET: resolveSecret('JWT_VERIFICATION_TOKEN_SECRET', 'verificationSecretKey'),
|
||||
get JWT_VERIFICATION_TOKEN_SECRET(): string {
|
||||
return resolveSecret('JWT_VERIFICATION_TOKEN_SECRET', 'verificationSecretKey');
|
||||
},
|
||||
JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_VERIFICATION_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default verification expire token time (7 days)
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,6 +194,26 @@ describe('resolveSecret (GHSA-39j7-x845-4w3c)', () => {
|
||||
expect(defaultConfiguration.authOptions.jwtSecret).toBe(environment.JWT_SECRET);
|
||||
expect(defaultConfiguration.authOptions.expressSessionSecret).toBe(environment.EXPRESS_SESSION_SECRET);
|
||||
});
|
||||
|
||||
// The API's entry point calls loadEnv() (which reads .env.local and friends) only AFTER its
|
||||
// imports have run, so this module can be evaluated while JWT_SECRET is still unset. Resolving
|
||||
// eagerly there decided the secret too early: this copy generated a random value, a copy
|
||||
// imported after loadEnv() read the configured one, and tokens signed with one were rejected by
|
||||
// the other — a 401 on every authenticated request. The published literal used to hide it,
|
||||
// because both copies then landed on the same literal.
|
||||
it('reads a secret configured AFTER this module was imported (load order)', () => {
|
||||
const { environment } = loadFresh<typeof import('./environment')>('./environment');
|
||||
const { defaultConfiguration } = loadFresh<typeof import('../default-config')>('../default-config');
|
||||
|
||||
// Imported with nothing set: a random per-process value, as above.
|
||||
expect(environment.JWT_SECRET).toMatch(/^[0-9a-f]{128}$/);
|
||||
|
||||
// ...then the env file is loaded, exactly as loadEnv() does at startup.
|
||||
process.env.JWT_SECRET = 'configured-by-load-env';
|
||||
|
||||
expect(environment.JWT_SECRET).toBe('configured-by-load-env');
|
||||
expect(defaultConfiguration.authOptions.jwtSecret).toBe('configured-by-load-env');
|
||||
});
|
||||
});
|
||||
|
||||
it('KNOWN_DEFAULT_SECRETS covers every literal the repository has shipped as a secret', () => {
|
||||
|
||||
Reference in New Issue
Block a user