| | | 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. Anything outside |
| | | 13 | | * `[A-Za-z0-9_./-]+` either makes the line unparseable, so the variable is |
| | | 14 | | * written and then silently lost (a space, `#`, a tab, an accented or |
| | | 15 | | * non-Latin letter, NUL, vertical tab), or is read back as a *different* |
| | | 16 | | * 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 | | * So does `/`, which map files have always used for hierarchical names |
| | | 27 | | * (`Database/Password`): it cannot inject anything, and envilder writes and |
| | | 28 | | * reads it back as itself. Some readers still skip such a line, which |
| | | 29 | | * {@link isPortableEnvFileName} reports. |
| | | 30 | | */ |
| | 25 | 31 | | const READABLE_NAME = /^[A-Za-z0-9_./-]+$/; |
| | | 32 | | |
| | | 33 | | /** |
| | | 34 | | * The key grammar every common `.env` reader shares. A name outside it but |
| | | 35 | | * inside {@link READABLE_NAME} (one holding `/`) round-trips through envilder |
| | | 36 | | * and `python-dotenv`, while `dotenv` for Node.js skips the line and a POSIX |
| | | 37 | | * shell cannot `source` it. |
| | | 38 | | */ |
| | 25 | 39 | | const PORTABLE_ENV_FILE_NAME = /^[A-Za-z0-9_.-]+$/; |
| | | 40 | | |
| | | 41 | | /** |
| | | 42 | | * `__proto__` is the one name inside the allowlist that still breaks the |
| | | 43 | | * round-trip. Writing it is fine, but every reader builds a plain object and |
| | | 44 | | * assigns into it, which routes the key through the `Object.prototype` |
| | | 45 | | * accessor instead of creating an own property -- `dotenv.parse` included, so |
| | | 46 | | * `dotenv.parse('__proto__=v')` returns `{}`. Accepting the name would mean |
| | | 47 | | * resolving the secret and writing a line that neither envilder nor any |
| | | 48 | | * dotenv-based consumer can read back, while reporting success. |
| | | 49 | | */ |
| | 25 | 50 | | const UNSUPPORTED_NAMES = new Set(['__proto__']); |
| | | 51 | | |
| | | 52 | | export function isValidEnvironmentVariableName(name: string): boolean { |
| | 452 | 53 | | return ( |
| | | 54 | | typeof name === 'string' && |
| | | 55 | | READABLE_NAME.test(name) && |
| | | 56 | | !UNSUPPORTED_NAMES.has(name) |
| | | 57 | | ); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | /** Whether every common `.env` reader loads a line with this valid name. */ |
| | | 61 | | export function isPortableEnvFileName(name: string): boolean { |
| | 130 | 62 | | return PORTABLE_ENV_FILE_NAME.test(name); |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | /** |
| | | 66 | | * Builds the rejection message, so every caller reports the same wording. The |
| | | 67 | | * name is quoted rather than interpolated: it is untrusted input, and quoting |
| | | 68 | | * keeps a name carrying line terminators from breaking the message across |
| | | 69 | | * lines. Only the name is echoed, never the mapped value. |
| | | 70 | | */ |
| | | 71 | | export function invalidEnvironmentVariableNameMessage(name: string): string { |
| | 45 | 72 | | if (typeof name !== 'string' || name.trim() === '') { |
| | 8 | 73 | | return 'Environment variable name cannot be empty'; |
| | | 74 | | } |
| | | 75 | | |
| | 37 | 76 | | if (UNSUPPORTED_NAMES.has(name)) { |
| | 2 | 77 | | return ( |
| | | 78 | | `Unsupported environment variable name ${quoteName(name)}: ` + |
| | | 79 | | 'a .env parser cannot read this name back, so it would be written ' + |
| | | 80 | | 'but never resolved' |
| | | 81 | | ); |
| | | 82 | | } |
| | | 83 | | |
| | 35 | 84 | | return ( |
| | | 85 | | `Invalid environment variable name ${quoteName(name)}: names may only ` + |
| | | 86 | | 'contain letters, digits, underscore, dot, hyphen and slash, so that ' + |
| | | 87 | | 'they can be read back from a .env file' |
| | | 88 | | ); |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | /** |
| | | 92 | | * Builds the warning for names a `.env` file holds but not every reader |
| | | 93 | | * loads. Names only, quoted for the same reason as the rejection message. |
| | | 94 | | */ |
| | | 95 | | export function nonPortableEnvFileNamesMessage(names: string[]): string { |
| | 3 | 96 | | const quoted = names.map(quoteName).join(', '); |
| | 3 | 97 | | return ( |
| | | 98 | | `${quoted} contain "/": envilder writes and reads them back, but dotenv ` + |
| | | 99 | | 'for Node.js skips those lines and a POSIX shell cannot source them. ' + |
| | | 100 | | 'If your app reads the .env file with one of those, use "envilder exec" ' + |
| | | 101 | | 'or rename them with "__" (Database__Password)' |
| | | 102 | | ); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | /** |
| | | 106 | | * Quotes an untrusted name for a message, so a name carrying a line |
| | | 107 | | * terminator cannot break the message across lines. |
| | | 108 | | */ |
| | | 109 | | export function quoteName(name: string): string { |
| | | 110 | | // JSON.stringify escapes CR, LF and the other control characters, but leaves |
| | | 111 | | // U+2028/U+2029 literal even though they terminate a line in JavaScript. |
| | 41 | 112 | | return JSON.stringify(name) |
| | | 113 | | .replace(/\u{2028}/gu, '\\u2028') |
| | | 114 | | .replace(/\u{2029}/gu, '\\u2029'); |
| | | 115 | | } |