| | | 1 | | /** |
| | | 2 | | * Pure domain rule describing which strings are usable as environment variable |
| | | 3 | | * names. Centralized here so every layer that accepts a name coming from a map |
| | | 4 | | * file or caller input (entity construction, mapping parsing, `.env` writing) |
| | | 5 | | * shares a single source of truth instead of duplicating the same regex and |
| | | 6 | | * message. |
| | | 7 | | * |
| | | 8 | | * The rule enforces one invariant: **an accepted name must survive a `.env` |
| | | 9 | | * round-trip as itself and nothing else.** |
| | | 10 | | * |
| | | 11 | | * That is expressed as an allowlist rather than a list of banned characters, |
| | | 12 | | * because a blocklist cannot hold the invariant. `.env` readers recognize a |
| | | 13 | | * key as `[A-Za-z0-9_.-]+`; anything outside that either makes the line |
| | | 14 | | * unparseable, so the variable is written and then silently lost (a space, |
| | | 15 | | * `#`, a tab, an accented or non-Latin letter, NUL, vertical tab), or is read |
| | | 16 | | * back as a *different* assignment, which is the injection in issue #511: |
| | | 17 | | * |
| | | 18 | | * - `=` opens a second assignment on the same line. |
| | | 19 | | * - CR and LF open a second line. |
| | | 20 | | * - U+2028 and U+2029 are JavaScript line terminators. The `.env` writer does |
| | | 21 | | * not split on them, but `dotenv` parses lines with a multiline regex whose |
| | | 22 | | * `^` matches after them, so a name embedding one is read back as a |
| | | 23 | | * separate assignment. |
| | | 24 | | * |
| | | 25 | | * Dotted and hyphenated names (historically accepted, non-POSIX) stay valid: |
| | | 26 | | * they are inside the allowlist and do round-trip. |
| | | 27 | | */ |
| | 16 | 28 | | const READABLE_NAME = /^[A-Za-z0-9_.-]+$/; |
| | | 29 | | |
| | | 30 | | /** |
| | | 31 | | * `__proto__` is the one name inside the allowlist that still breaks the |
| | | 32 | | * round-trip. Writing it is fine, but every reader builds a plain object and |
| | | 33 | | * assigns into it, which routes the key through the `Object.prototype` |
| | | 34 | | * accessor instead of creating an own property -- `dotenv.parse` included, so |
| | | 35 | | * `dotenv.parse('__proto__=v')` returns `{}`. Accepting the name would mean |
| | | 36 | | * resolving the secret and writing a line that neither envilder nor any |
| | | 37 | | * dotenv-based consumer can read back, while reporting success. |
| | | 38 | | */ |
| | 16 | 39 | | const UNSUPPORTED_NAMES = new Set(['__proto__']); |
| | | 40 | | |
| | | 41 | | export function isValidEnvironmentVariableName(name: string): boolean { |
| | 224 | 42 | | return ( |
| | | 43 | | typeof name === 'string' && |
| | | 44 | | READABLE_NAME.test(name) && |
| | | 45 | | !UNSUPPORTED_NAMES.has(name) |
| | | 46 | | ); |
| | | 47 | | } |
| | | 48 | | |
| | | 49 | | /** |
| | | 50 | | * Builds the rejection message, so every caller reports the same wording. The |
| | | 51 | | * name is quoted rather than interpolated: it is untrusted input, and quoting |
| | | 52 | | * keeps a name carrying line terminators from breaking the message across |
| | | 53 | | * lines. Only the name is echoed, never the mapped value. |
| | | 54 | | */ |
| | | 55 | | export function invalidEnvironmentVariableNameMessage(name: string): string { |
| | 38 | 56 | | if (typeof name !== 'string' || name.trim() === '') { |
| | 8 | 57 | | return 'Environment variable name cannot be empty'; |
| | | 58 | | } |
| | | 59 | | |
| | 30 | 60 | | if (UNSUPPORTED_NAMES.has(name)) { |
| | 1 | 61 | | return ( |
| | | 62 | | `Unsupported environment variable name ${quoteName(name)}: ` + |
| | | 63 | | 'a .env parser cannot read this name back, so it would be written ' + |
| | | 64 | | 'but never resolved' |
| | | 65 | | ); |
| | | 66 | | } |
| | | 67 | | |
| | 29 | 68 | | return ( |
| | | 69 | | `Invalid environment variable name ${quoteName(name)}: names may only ` + |
| | | 70 | | 'contain letters, digits, underscore, dot and hyphen, so that they can ' + |
| | | 71 | | 'be read back from a .env file' |
| | | 72 | | ); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | function quoteName(name: string): string { |
| | | 76 | | // JSON.stringify escapes CR, LF and the other control characters, but leaves |
| | | 77 | | // U+2028/U+2029 literal even though they terminate a line in JavaScript. |
| | 30 | 78 | | return JSON.stringify(name) |
| | | 79 | | .replace(/\u{2028}/gu, '\\u2028') |
| | | 80 | | .replace(/\u{2029}/gu, '\\u2029'); |
| | | 81 | | } |