validateEnv.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. // lib/config/validateEnv.js
  2. export const REQUIRED_ENV_VARS = [
  3. "MONGODB_URI",
  4. "SESSION_SECRET",
  5. "NAS_ROOT_PATH",
  6. ];
  7. export const ALLOWED_NODE_ENVS = new Set(["development", "test", "production"]);
  8. export const MIN_SESSION_SECRET_LENGTH = 32;
  9. function isBlank(value) {
  10. return value === undefined || value === null || String(value).trim() === "";
  11. }
  12. function normalizeString(value) {
  13. return String(value).trim();
  14. }
  15. function normalizeUnixPath(value) {
  16. let p = normalizeString(value);
  17. if (p.length > 1) p = p.replace(/\/+$/, "");
  18. return p;
  19. }
  20. function containsDotDotSegment(p) {
  21. return /(^|\/)\.\.(\/|$)/.test(p);
  22. }
  23. function looksLikePlaceholderSecret(value) {
  24. const s = normalizeString(value).toLowerCase();
  25. return (
  26. s.includes("change-me") ||
  27. s.includes("changeme") ||
  28. s.includes("replace-me") ||
  29. s.includes("replace_this") ||
  30. s === "secret" ||
  31. s === "password"
  32. );
  33. }
  34. function validateMongoUri(uri) {
  35. return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
  36. }
  37. function parsePort(value) {
  38. const raw = normalizeString(value);
  39. if (!/^\d+$/.test(raw)) return { ok: false, value: null };
  40. const n = Number(raw);
  41. if (!Number.isInteger(n) || n < 1 || n > 65535)
  42. return { ok: false, value: null };
  43. return { ok: true, value: n };
  44. }
  45. function buildEnvError(missing, invalid) {
  46. const lines = [];
  47. lines.push("Invalid environment configuration.");
  48. if (missing.length > 0) {
  49. lines.push("");
  50. lines.push("Missing required environment variables:");
  51. for (const key of missing) lines.push(`- ${key}`);
  52. }
  53. if (invalid.length > 0) {
  54. lines.push("");
  55. lines.push("Invalid environment variables:");
  56. for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`);
  57. }
  58. lines.push("");
  59. lines.push(
  60. "Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)."
  61. );
  62. const err = new Error(lines.join("\n"));
  63. err.code = "ENV_INVALID";
  64. err.missing = missing;
  65. err.invalid = invalid;
  66. return err;
  67. }
  68. export function validateEnv(env) {
  69. const e = env ?? {};
  70. const missing = [];
  71. const invalid = [];
  72. for (const key of REQUIRED_ENV_VARS) {
  73. if (isBlank(e[key])) missing.push(key);
  74. }
  75. const mongodbUri = !isBlank(e.MONGODB_URI)
  76. ? normalizeString(e.MONGODB_URI)
  77. : "";
  78. if (mongodbUri && !validateMongoUri(mongodbUri)) {
  79. invalid.push({
  80. key: "MONGODB_URI",
  81. message: 'must start with "mongodb://" or "mongodb+srv://"',
  82. });
  83. }
  84. const sessionSecret = !isBlank(e.SESSION_SECRET)
  85. ? normalizeString(e.SESSION_SECRET)
  86. : "";
  87. if (sessionSecret) {
  88. if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
  89. invalid.push({
  90. key: "SESSION_SECRET",
  91. message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`,
  92. });
  93. }
  94. if (looksLikePlaceholderSecret(sessionSecret)) {
  95. invalid.push({
  96. key: "SESSION_SECRET",
  97. message:
  98. "looks like a placeholder (replace it with a strong random secret)",
  99. });
  100. }
  101. }
  102. const nasRootPath = !isBlank(e.NAS_ROOT_PATH)
  103. ? normalizeUnixPath(e.NAS_ROOT_PATH)
  104. : "";
  105. if (nasRootPath) {
  106. if (!nasRootPath.startsWith("/")) {
  107. invalid.push({
  108. key: "NAS_ROOT_PATH",
  109. message: 'must be an absolute Unix path (starts with "/")',
  110. });
  111. }
  112. if (containsDotDotSegment(nasRootPath)) {
  113. invalid.push({
  114. key: "NAS_ROOT_PATH",
  115. message: 'must not contain ".." path segments',
  116. });
  117. }
  118. }
  119. const nodeEnvRaw = !isBlank(e.NODE_ENV)
  120. ? normalizeString(e.NODE_ENV)
  121. : "development";
  122. if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) {
  123. invalid.push({
  124. key: "NODE_ENV",
  125. message: 'must be one of "development", "test", "production"',
  126. });
  127. }
  128. // Optional: cookie secure override
  129. const cookieSecureRaw = !isBlank(e.SESSION_COOKIE_SECURE)
  130. ? normalizeString(e.SESSION_COOKIE_SECURE).toLowerCase()
  131. : "";
  132. if (
  133. cookieSecureRaw &&
  134. cookieSecureRaw !== "true" &&
  135. cookieSecureRaw !== "false"
  136. ) {
  137. invalid.push({
  138. key: "SESSION_COOKIE_SECURE",
  139. message: 'must be "true" or "false" if provided',
  140. });
  141. }
  142. let port;
  143. if (!isBlank(e.PORT)) {
  144. const parsed = parsePort(e.PORT);
  145. if (!parsed.ok) {
  146. invalid.push({
  147. key: "PORT",
  148. message: "must be an integer between 1 and 65535",
  149. });
  150. } else {
  151. port = parsed.value;
  152. }
  153. }
  154. if (missing.length > 0 || invalid.length > 0) {
  155. throw buildEnvError(missing, invalid);
  156. }
  157. const cfg = {
  158. mongodbUri,
  159. sessionSecret,
  160. nasRootPath,
  161. nodeEnv: nodeEnvRaw,
  162. };
  163. if (port !== undefined) cfg.port = port;
  164. return cfg;
  165. }