Skip to content

Commit be50ae8

Browse files
committed
fix(env): improve env() handling for numeric strings and preserve exact values
1 parent cd4fcab commit be50ae8

4 files changed

Lines changed: 103 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ The format follows semantic versioning principles.
1010

1111
### Changed
1212

13+
- `env()` now reads a value as a number only when the number says exactly what was written. `Number()` accepts hexadecimal, binary and octal literals, exponents, surrounding whitespace, and integers wider than a double can hold, and coercing those loses information silently. `'6379'` is still a number; `'0x1f'`, `'007'`, `'1e5'`, `' 12 '`, `'0b1010'`, `'Infinity'` and `'12345678901234567890'` are now the strings they were written as.
14+
1315
### Docs
1416

1517
### Fixed
1618

19+
- Fixed `env()` destroying hexadecimal secrets. A `0x`-prefixed 32-byte key parses as a number, so it was silently replaced by a float keeping roughly its first thirteen digits, with nothing downstream able to tell. Private keys, hashes and identifiers read through `env()` now survive verbatim.
20+
1721
## [0.12.36] - 2026-06-20
1822

1923
### Added

‎packages/common/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,10 @@
6464
"arkormx": {
6565
"optional": true
6666
}
67+
},
68+
"inlinedDependencies": {
69+
"clear-router": "2.9.3",
70+
"dayjs": "1.11.20",
71+
"kanun": "1.2.0"
6772
}
6873
}

‎packages/common/src/EnvLoader.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export class EnvLoader {
4444
* Read an environment variable, coercing booleans, numbers and `null`, and
4545
* falling back to `defaultValue` when it is unset.
4646
*
47+
* A value is only read as a number when the number says exactly what was
48+
* written. See {@link isExactNumber}.
49+
*
4750
* @param name The variable name.
4851
* @param defaultValue Returned when the variable is unset.
4952
*/
@@ -60,12 +63,7 @@ export class EnvLoader {
6063
val = [true, 'true', 'on'].includes(val)
6164
}
6265

63-
if (
64-
!isNaN(Number(val)) &&
65-
typeof val !== 'boolean' &&
66-
typeof val !== 'undefined' &&
67-
val !== ''
68-
) {
66+
if (typeof val === 'string' && this.isExactNumber(val)) {
6967
val = Number(val)
7068
}
7169

@@ -81,6 +79,27 @@ export class EnvLoader {
8179

8280
return val as EnvReturn<X, K, D>
8381
}
82+
83+
/**
84+
* Whether a number would say exactly what the variable says.
85+
*
86+
* `Number()` accepts far more than decimal digits: hexadecimal, binary and
87+
* octal literals, exponents, surrounding whitespace, and integers wider
88+
* than a double can hold. Coercing those loses information silently, and
89+
* the values that look like numbers by accident tend to be the ones where
90+
* that matters most - a 32-byte key beginning `0x` becomes a float that
91+
* keeps roughly its first thirteen digits, and nothing downstream can tell
92+
* that it happened.
93+
*
94+
* Converting back and comparing is the whole test: `'6379'` survives it,
95+
* `'0x1f'`, `'007'`, `'1e5'` and `'12345678901234567890'` do not, and so
96+
* they stay the strings they were written as.
97+
*/
98+
private isExactNumber(value: string): boolean {
99+
const parsed = Number(value)
100+
101+
return Number.isFinite(parsed) && String(parsed) === value
102+
}
84103
}
85104

86105
/**

‎packages/common/tests/env-loader.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,73 @@ describe('EnvLoader', () => {
4949
process.env[KEY] = 'localhost'
5050
expect(loader.get(KEY)).toBe('localhost')
5151
})
52+
53+
/**
54+
* `Number()` accepts far more than decimal digits, and the values that
55+
* look like numbers by accident are often the ones where losing them
56+
* matters most. A number is only used when it says exactly what was
57+
* written.
58+
*/
59+
describe('values that only look like numbers', () => {
60+
const keeps = (written: string) => {
61+
const loader = new EnvLoader()
62+
63+
process.env[KEY] = written
64+
65+
expect(loader.get(KEY)).toBe(written)
66+
}
67+
68+
test('keeps a hexadecimal key, which a number would destroy', () => {
69+
// 32 bytes. As a number this keeps about its first thirteen digits.
70+
keeps(
71+
'0xd6d9ef6d3a5443f0de69a1452d0cb5a62bdd9700225c3c8371db575aa298408c',
72+
)
73+
})
74+
75+
test('keeps a short hexadecimal value', () => {
76+
keeps('0x1f')
77+
})
78+
79+
test('keeps binary and octal literals', () => {
80+
keeps('0b1010')
81+
keeps('0o755')
82+
})
83+
84+
test('keeps leading zeros, which carry meaning of their own', () => {
85+
keeps('007')
86+
})
87+
88+
test('keeps an integer wider than a double can hold', () => {
89+
keeps('12345678901234567890')
90+
})
91+
92+
test('keeps exponent notation as it was written', () => {
93+
keeps('1e5')
94+
})
95+
96+
test('keeps surrounding whitespace rather than trimming silently', () => {
97+
keeps(' 12 ')
98+
})
99+
100+
test('keeps infinities out of configuration', () => {
101+
keeps('Infinity')
102+
keeps('-Infinity')
103+
})
104+
105+
test('still reads an ordinary number as a number', () => {
106+
const loader = new EnvLoader()
107+
108+
process.env[KEY] = '6379'
109+
expect(loader.get(KEY)).toBe(6379)
110+
111+
process.env[KEY] = '-3'
112+
expect(loader.get(KEY)).toBe(-3)
113+
114+
process.env[KEY] = '1.5'
115+
expect(loader.get(KEY)).toBe(1.5)
116+
117+
process.env[KEY] = '0'
118+
expect(loader.get(KEY)).toBe(0)
119+
})
120+
})
52121
})

0 commit comments

Comments
 (0)