Validate device names on rename so users don't brick a device profile - #143
Conversation
A device name containing `/` bricks that device. The client escapes it
correctly with encodeURIComponent, but nginx decodes `%2F` before proxying, so
`/api/devices/<name>` splits into a path that matches no route. The dashboard
stops rendering, and the device cannot be deleted through the UI either — that
request takes the same shape.
Validate the name where a human chooses one: the rename route. Reads are
deliberately left alone. An existing row's name is a fact about the world, and
rejecting a name we previously accepted would break displays that work today —
including on upgrade, which is the worst possible moment.
The rule is an allow-list rather than a deny-list, so it stays correct if
HomeGlow is fronted by Caddy or Apache, or by nginx with different
merge_slashes settings:
/^[\p{L}\p{N} _\-.'()]+$/u
Unicode letters and digits, space, and the punctuation people actually use
when naming a screen in their house — "Kitchen Display", "Nan's iPad",
"Playroom (up)", "José". Characters carrying URL meaning (/ \ % ? # & + : @)
are excluded even where the current nginx tolerates them. Names are
NFC-normalised so an accented name typed two ways cannot produce two devices
that look identical, must be 1-64 characters after trimming, must contain at
least one letter or digit, and must not contain ".." .
Mirrors the server rule in the rename dialog so the 400 is a backstop rather than the user's first experience of the constraint: the confirm action is disabled and inline helper text explains what is allowed. The two implementations are checked against each other for agreement — an earlier revision diverged because `RegExp.test()` coerces its argument, so `isValidDeviceName(undefined)` tested the string "undefined" and returned true on the client while the server returned false. getDeviceName is deliberately unchanged: it returns exactly what is stored and never rewrites it. Repairing an install that already holds an invalid name would mean mutating every client's stored identity on every page load, forever, to fix a rare condition — so this prevents new breakage rather than attempting to repair old.
caa542f to
af88508
Compare
…ollow-up) The allow-list added in #143 is letters, digits and a little punctuation. Plenty of scripts do not fit that: Devanagari, Thai, Punjabi, Sinhala and Khmer carry their vowel signs and viramas in the Unicode Mark category, so a perfectly ordinary "kitchen" in Hindi or Thai was rejected on rename. Adding \p{M} to the allow-list fixes those without loosening anything that matters. Slash, percent, question mark, hash and dot-dot stay rejected, and so do zero-width joiners and bidi overrides, which are format characters (\p{Cf}) rather than marks. DEVICE_NAME_HAS_ALNUM is deliberately left alone, so a name still has to contain a real letter or digit and a string of bare combining marks is not a name. Tests on both sides cover the three scripts and pin the controls that must stay out. 208 server, 122 client, build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merged — nice piece of work. The reasoning about validating on rename only, and deliberately not shipping a migration, is the right call and well argued. One little tweak pushed on top in b7951d2: the allow-list was Admittedly this only unblocks our enormous install base across India and Thailand, who I'm sure have been queuing up to name a kitchen tablet — but the fix was one character class, so it seemed rude not to. Tests added both sides. 208 backend / 122 frontend, build clean. |
A device name containing
/bricks that device: the dashboard stops rendering, and the device cannot be deleted through the UI either.The failure
The client escapes the name correctly —
getDeviceApiBaseusesencodeURIComponent, so/becomes%2F. nginx decodes it before proxying, so/api/devices/<name>/settingssplits into a path that matches no route, and every request for that device 404s. Deleting it goes through the same path shape, so the UI offers no way out.Reproduced on a stock instance:
The fix, and one thing it deliberately does not do
Validation happens on rename only — the one place a human chooses a name. Reads are untouched, and
ensureDeviceExistsis unchanged.That restraint is the important part. An earlier revision of this change validated on the lookup path too, which meant an existing device called
Kitchen Displaywould start failing on upgrade — breaking displays that work today in order to fix ones that are already broken. An existing row's name is a fact about the world; rejecting a name the application previously accepted and stored is not a fix.For the same reason there is no migration and no sweep. Repairing an install that already holds a bad name would require rewriting the name server-side and in every browser's
localStorage, since the client holds its own identity and would keep requesting the old one. That is a permanent cost on every page load of every install, to fix a rare condition.getDeviceNamestill returns exactly what is stored.The rule
An allow-list rather than a deny-list, so it stays correct if HomeGlow is fronted by Caddy or Apache, or by nginx with different
merge_slashessettings:/^[\p{L}\p{N} _\-.'()]+$/uUnicode letters and digits, space, and the punctuation people actually use for a screen in their house —
Kitchen Display,Nan's iPad,Playroom (up),José. Characters carrying URL meaning (/ \ % ? # & + : @) are excluded even where the current nginx tolerates them.Plus: NFC-normalised so an accented name typed two ways cannot produce two devices that look identical; 1–64 characters after trimming; at least one letter or digit; no
...The charset is intentionally broader than the bug strictly requires. Only
/is known to break today — but%,?and#have URL meaning that a different front end may well act on, and a device name has no need of them.Client side
The rename dialog validates as you type, disabling the confirm action with inline helper text, so the server 400 is a backstop rather than the user's first encounter with the rule. Both implementations are checked against each other; an earlier revision diverged because
RegExp.prototype.test()coerces its argument, soisValidDeviceName(undefined)tested the string"undefined"and returnedtrueon the client while the server returnedfalse.Testing
server/tests/deviceName.test.jsandclient/src/utils/deviceName.test.jscover the valid names above,/,.., whitespace-only, pure punctuation, over-length, and non-string inputs — plus a case asserting the two NFC spellings of an accented name resolve to the same device.Full suites on Node 20 (matching
ci-tests.yml): backend 190 passing, frontend 120 passing,check:i18n715/715, client build clean.