Description
Summary
Creating a pre-auth key from the web UI breaks whenever "Key Expiration" is >= 1000. Depending on the browser locale it either returns a 500, or — worse — silently creates the key with a completely wrong expiry and no error at all.
The Key Expiration field is a NumberInput (defaultValue: 90, min: 1, max: 365000). Values >= 1000 are formatted by react-aria via Intl.NumberFormat using the browser locale before the form is submitted, so the server receives a grouped string such as 365,000, 365 000 or 365.000 rather than 365000.
Root cause
The action handler parses the submitted value as:
const day = Number(expiry.toString().split(" ")[0]);
const date = new Date();
date.setDate(date.getDate() + day);
split(" ") exists to strip a "90 days"-style suffix, but it only splits on U+0020 and a grouped number may contain no plain space at all. The group separator therefore survives into Number().
Locale matrix
Formatting 365000 through Intl.NumberFormat, then parsing with the code above:
| Locale |
Submitted |
Separator |
Parsed |
Result |
| en-US |
365,000 |
U+002C , |
NaN |
500 |
| en-GB |
365,000 |
U+002C , |
NaN |
500 |
| ru-RU |
365 000 |
U+00A0 NBSP |
NaN |
500 |
| pl-PL |
365 000 |
U+00A0 NBSP |
NaN |
500 |
| fr-FR |
365 000 |
U+202F narrow NBSP |
NaN |
500 |
| de-DE |
365.000 |
U+002E . |
365 |
silent wrong expiry |
| es-ES |
365.000 |
U+002E . |
365 |
silent wrong expiry |
Two distinct failure modes:
- Comma / non-breaking-space locales —
NaN reaches setDate(), producing an Invalid Date, and preAuthKeys.create() throws on expiration.toISOString() => 500, no key created. Note that NBSP locales look like they contain a space, but split(" ") does not match U+00A0 or U+202F.
- Dot-grouping locales (de-DE, es-ES) —
Number("365.000") reads the separator as a decimal point and returns 365. No error is raised: the key is created and appears valid, but expires in 365 days instead of 365000. This is the more dangerous case, since nothing surfaces the discrepancy to the user.
Client-side validation passes in every case (the value is within min/max), so the UI shows no field error.
Steps to reproduce
- Settings -> Auth Keys -> Generate auth key
- Select any user
- Set Key Expiration to
1000 (or type 999999999, which clamps to 365000)
- Submit
Expected: key is created with the requested expiry.
Actual: 500 and no key on en-US/en-GB/ru-RU/pl-PL/fr-FR; on de-DE/es-ES a key is created with a silently truncated expiry.
The threshold is exactly 1000 — the point at which grouping kicks in. Any value 1–999 works correctly in all locales.
Server log
RangeError: Invalid time value
at Date.toISOString (<anonymous>)
at Object.create (file:///app/build/server/index.js:165416:41)
at authKeysAction (file:///app/build/server/index.js:192479:33)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async callRouteHandler (file:///app/build/server/index.js:13860:15)
at async file:///app/build/server/index.js:8874:14
at async callLoaderOrAction (file:///app/build/server/index.js:8918:19)
Workaround
Use an expiry below 1000 days, or create the key via CLI:
headscale preauthkeys create --user <id> --expiration 8760h --reusable
Suggested fix
Submit the NumberInput's raw numeric value rather than its formatted string — this avoids locale-dependent parsing entirely and is the more robust option. If the formatted string must be parsed server-side, strip all non-numeric characters first (Number(expiry.replace(/[^\d]/g, ""))); note that a naive replace(/,/g, "") would fix only the comma locales and leave both the NBSP and the silent dot-grouping cases broken.
Relation to #125
Distinct issue. #125 concerns a localized unit suffix ("90 Tage", "90 días"). Here the separator sits inside the number itself, the field carries no unit suffix, and the bug reproduces on en-US. Fixing #125 by normalizing the suffix would not address this.
Headplane Version
0.7.0
Headscale Version
v0.29.2
Description
Summary
Creating a pre-auth key from the web UI breaks whenever "Key Expiration" is >= 1000. Depending on the browser locale it either returns a 500, or — worse — silently creates the key with a completely wrong expiry and no error at all.
The
Key Expirationfield is aNumberInput(defaultValue: 90,min: 1,max: 365000). Values >= 1000 are formatted by react-aria viaIntl.NumberFormatusing the browser locale before the form is submitted, so the server receives a grouped string such as365,000,365 000or365.000rather than365000.Root cause
The action handler parses the submitted value as:
split(" ")exists to strip a"90 days"-style suffix, but it only splits on U+0020 and a grouped number may contain no plain space at all. The group separator therefore survives intoNumber().Locale matrix
Formatting
365000throughIntl.NumberFormat, then parsing with the code above:365,000,NaN365,000,NaN365 000NaN365 000NaN365 000NaN365.000.365365.000.365Two distinct failure modes:
NaNreachessetDate(), producing an Invalid Date, andpreAuthKeys.create()throws onexpiration.toISOString()=> 500, no key created. Note that NBSP locales look like they contain a space, butsplit(" ")does not match U+00A0 or U+202F.Number("365.000")reads the separator as a decimal point and returns365. No error is raised: the key is created and appears valid, but expires in 365 days instead of 365000. This is the more dangerous case, since nothing surfaces the discrepancy to the user.Client-side validation passes in every case (the value is within
min/max), so the UI shows no field error.Steps to reproduce
1000(or type999999999, which clamps to365000)Expected: key is created with the requested expiry.
Actual: 500 and no key on en-US/en-GB/ru-RU/pl-PL/fr-FR; on de-DE/es-ES a key is created with a silently truncated expiry.
The threshold is exactly 1000 — the point at which grouping kicks in. Any value 1–999 works correctly in all locales.
Server log
Workaround
Use an expiry below 1000 days, or create the key via CLI:
Suggested fix
Submit the
NumberInput's raw numeric value rather than its formatted string — this avoids locale-dependent parsing entirely and is the more robust option. If the formatted string must be parsed server-side, strip all non-numeric characters first (Number(expiry.replace(/[^\d]/g, ""))); note that a naivereplace(/,/g, "")would fix only the comma locales and leave both the NBSP and the silent dot-grouping cases broken.Relation to #125
Distinct issue. #125 concerns a localized unit suffix (
"90 Tage","90 días"). Here the separator sits inside the number itself, the field carries no unit suffix, and the bug reproduces onen-US. Fixing #125 by normalizing the suffix would not address this.Headplane Version
0.7.0
Headscale Version
v0.29.2