- accept wildcard proxy host domains like *.example.com with validation and normalization - make exact hosts win over overlapping wildcards in generated routes and TLS policies - add unit coverage for host-pattern priority and wildcard domain handling - add a single test:all entry point and clean up lint/typecheck issues so the suite runs cleanly - run mobile layout Playwright checks under both chromium and mobile-iphone
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
const DEV_SECRET = "dev-secret-change-in-production-12345678901234567890123456789012";
|
|
const DEFAULT_ADMIN_USERNAME = "admin";
|
|
const DEFAULT_ADMIN_PASSWORD = "admin";
|
|
const DISALLOWED_SESSION_SECRETS = new Set([
|
|
"change-me-in-production",
|
|
"dev-secret-change-in-production-12345678901234567890123456789012"
|
|
]);
|
|
const DEFAULT_CADDY_URL = process.env.NODE_ENV === "development" ? "http://localhost:2019" : "http://caddy:2019";
|
|
const MIN_SESSION_SECRET_LENGTH = 32;
|
|
const MIN_ADMIN_PASSWORD_LENGTH = 12;
|
|
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
const isNodeRuntime = process.env.NEXT_RUNTIME === "nodejs";
|
|
const isDevelopment = process.env.NODE_ENV === "development";
|
|
// Only enforce strict validation in actual production runtime, not during build
|
|
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build" || !process.env.NEXT_RUNTIME;
|
|
const isRuntimeProduction = isProduction && isNodeRuntime && !isBuildPhase;
|
|
|
|
function resolveSessionSecret(): string {
|
|
const rawSecret = process.env.SESSION_SECRET ?? null;
|
|
const secret = rawSecret?.trim();
|
|
|
|
// In development, allow missing secret
|
|
if (isDevelopment && !secret) {
|
|
return DEV_SECRET;
|
|
}
|
|
|
|
// In production build phase, allow temporary value
|
|
if (isProduction && !isNodeRuntime && !secret) {
|
|
return DEV_SECRET;
|
|
}
|
|
|
|
// Use provided secret or dev secret
|
|
const finalSecret = secret || DEV_SECRET;
|
|
|
|
// Strict validation in production runtime
|
|
if (isRuntimeProduction) {
|
|
if (!secret) {
|
|
throw new Error(
|
|
"SESSION_SECRET environment variable is required in production. " +
|
|
"Generate a secure secret with: openssl rand -base64 32"
|
|
);
|
|
}
|
|
if (DISALLOWED_SESSION_SECRETS.has(secret)) {
|
|
throw new Error(
|
|
"SESSION_SECRET is using a known insecure placeholder value. " +
|
|
"Generate a secure secret with: openssl rand -base64 32"
|
|
);
|
|
}
|
|
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
throw new Error(
|
|
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long in production. ` +
|
|
"Generate a secure secret with: openssl rand -base64 32"
|
|
);
|
|
}
|
|
}
|
|
|
|
return finalSecret;
|
|
}
|
|
|
|
function resolveAdminCredentials() {
|
|
const rawUsername = process.env.ADMIN_USERNAME ?? null;
|
|
const rawPassword = process.env.ADMIN_PASSWORD ?? null;
|
|
const username = rawUsername?.trim() || DEFAULT_ADMIN_USERNAME;
|
|
const password = rawPassword?.trim() || DEFAULT_ADMIN_PASSWORD;
|
|
|
|
// In development, allow defaults
|
|
if (isDevelopment) {
|
|
if (username === DEFAULT_ADMIN_USERNAME || password === DEFAULT_ADMIN_PASSWORD) {
|
|
console.log("Using default admin credentials for development (admin/admin)");
|
|
}
|
|
return { username, password };
|
|
}
|
|
|
|
// In production build phase, allow defaults temporarily
|
|
if (isProduction && !isNodeRuntime) {
|
|
return { username, password };
|
|
}
|
|
|
|
// Strict validation in production runtime
|
|
if (isRuntimeProduction) {
|
|
const errors: string[] = [];
|
|
|
|
// Username validation - just ensure it's set
|
|
if (!rawUsername || !username) {
|
|
errors.push(
|
|
"ADMIN_USERNAME must be set"
|
|
);
|
|
}
|
|
|
|
// Password validation - strict requirements
|
|
if (!rawPassword || password === DEFAULT_ADMIN_PASSWORD) {
|
|
errors.push(
|
|
"ADMIN_PASSWORD must be set to a custom value in production (not 'admin')"
|
|
);
|
|
} else {
|
|
if (password.length < MIN_ADMIN_PASSWORD_LENGTH) {
|
|
errors.push(
|
|
`ADMIN_PASSWORD must be at least ${MIN_ADMIN_PASSWORD_LENGTH} characters long`
|
|
);
|
|
}
|
|
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) {
|
|
errors.push(
|
|
"ADMIN_PASSWORD must include both uppercase and lowercase letters"
|
|
);
|
|
}
|
|
if (!/[0-9]/.test(password)) {
|
|
errors.push(
|
|
"ADMIN_PASSWORD must include at least one number"
|
|
);
|
|
}
|
|
if (!/[^A-Za-z0-9]/.test(password)) {
|
|
errors.push(
|
|
"ADMIN_PASSWORD must include at least one special character"
|
|
);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
"Admin credentials validation failed:\n" +
|
|
errors.map(e => ` - ${e}`).join("\n") +
|
|
"\n\nSet secure credentials using ADMIN_USERNAME and ADMIN_PASSWORD environment variables."
|
|
);
|
|
}
|
|
}
|
|
|
|
return { username, password };
|
|
}
|
|
|
|
// Lazy initialization to avoid executing during build time
|
|
let _adminCredentials: { username: string; password: string } | null = null;
|
|
let _sessionSecret: string | null = null;
|
|
|
|
function getAdminCredentials() {
|
|
if (!_adminCredentials) {
|
|
_adminCredentials = resolveAdminCredentials();
|
|
}
|
|
return _adminCredentials;
|
|
}
|
|
|
|
function getSessionSecret() {
|
|
if (!_sessionSecret) {
|
|
_sessionSecret = resolveSessionSecret();
|
|
}
|
|
return _sessionSecret;
|
|
}
|
|
|
|
export const config = {
|
|
get sessionSecret() {
|
|
return getSessionSecret();
|
|
},
|
|
caddyApiUrl: process.env.CADDY_API_URL ?? DEFAULT_CADDY_URL,
|
|
baseUrl: process.env.BASE_URL ?? "http://localhost:3000",
|
|
get adminUsername() {
|
|
return getAdminCredentials().username;
|
|
},
|
|
get adminPassword() {
|
|
return getAdminCredentials().password;
|
|
},
|
|
oauth: {
|
|
enabled: process.env.OAUTH_ENABLED === "true",
|
|
providerName: process.env.OAUTH_PROVIDER_NAME ?? "OAuth2",
|
|
clientId: process.env.OAUTH_CLIENT_ID ?? null,
|
|
clientSecret: process.env.OAUTH_CLIENT_SECRET ?? null,
|
|
issuer: process.env.OAUTH_ISSUER ?? null,
|
|
authorizationUrl: process.env.OAUTH_AUTHORIZATION_URL ?? null,
|
|
tokenUrl: process.env.OAUTH_TOKEN_URL ?? null,
|
|
userinfoUrl: process.env.OAUTH_USERINFO_URL ?? null,
|
|
allowAutoLinking: process.env.OAUTH_ALLOW_AUTO_LINKING === "true",
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validates configuration at server startup in production.
|
|
* Throws if production is running with insecure default values.
|
|
* Safe to call during build - only validates when actually serving.
|
|
*/
|
|
export function validateProductionConfig() {
|
|
if (isRuntimeProduction) {
|
|
// Force validation by accessing the config values
|
|
// This will throw if defaults are being used in production
|
|
void config.sessionSecret;
|
|
void config.adminUsername;
|
|
void config.adminPassword;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns list of enabled OAuth providers based on configuration.
|
|
* Only includes providers that have complete credentials configured.
|
|
*/
|
|
export function getEnabledOAuthProviders(): Array<{id: string; name: string}> {
|
|
const providers: Array<{id: string; name: string}> = [];
|
|
|
|
if (
|
|
config.oauth.enabled &&
|
|
config.oauth.clientId &&
|
|
config.oauth.clientSecret &&
|
|
config.oauth.issuer
|
|
) {
|
|
providers.push({
|
|
id: "oauth2",
|
|
name: config.oauth.providerName
|
|
});
|
|
}
|
|
|
|
return providers;
|
|
}
|