Skip to content

Client-name sanitiser misses SS3 escapes (ESC O) — they survive as a plausible name and mint a permanent namespace #516

Description

@LukasWodka

Summary

The escape-sequence sanitiser handles CSI (ESC [ … final) only. It does not handle SS3 (ESC O final), which is what the arrow keys emit when the terminal is in DECCKM application-cursor mode — the state vim, less or tmux leave behind on an unclean exit. Unlike the CSI case, SS3 residue survives as a non-empty, plausible-looking name and mints an immutable namespace.

There is also no minimum-viable name rule at any layer, and the backend's namespace validator cannot catch this by construction.

Not the 2026-07-20 bug — that one is fixed

The original report ([D[D[D[A[A[A becoming a client name) was fixed the next day, at two layers: cli#364 (strip control chars before slugifying) and client#362 (helper moved to common.sh, applied at the name prompt). Verified by running the real sanitiser on the exact bytes — 1b 5b 44 ×3 / 1b 5b 41 ×3 → empty string → the non-empty check fails → re-prompt ×3 → hard error. Fails closed. Please don't re-litigate that part.

The remaining hole

Three copies of the pattern, all CSI-only:

  • cli/internal/cli/sanitize.go:13
  • client/scripts/lib/common.sh:378
  • client/scripts/install-k8s.ps1:4152

Measured against the real Go functions:

"\x1bOD\x1bOD\x1bOD\x1bOA\x1bOA\x1bOA"   ->  "ODODODOAOAOA"   ->  slug "odododoaoaoa"
"\x1bOH\x1bOF"   (Home/End)              ->  "OHOF"           ->  slug "ohof"
"\x1bOP\x1bOQ"   (F1/F2)                 ->  "OPOQ"           ->  slug "opoq"

The ESC is removed as a control byte; O and the final byte are printable, so the result passes the non-empty check and is accepted verbatim.

Test coverage shows the shape of the gap exactly: sanitize_test.go has six CSI cases (\x1b[D, \x1b[1;5D, \x1b[3~, both paste wrappers, a lone ESC) and zero \x1bO. client/scripts/tests/install-client-helm.bats:115-123 likewise has two cases, both CSI.

Server-side validation cannot catch this

is_dns1123_label (backend/common/utils/slug.py:80) validates by idempotence against the slug rule: slugify_dns1123("d-d-d-a-a-a") == "d-d-d-a-a-a", so escape-derived garbage is a perfectly canonical DNS-1123 label. The server checks form, and form is precisely what this bug preserves.

Nor does anything else: the CLI accepts any non-empty string after sanitising (client.go:192-199 — empty auto-names, non-empty is taken as-is), and EdgeDevice inherits first_name from User with only Django's max-length/non-blank defaults. There is no validate_first_name in the backend at all.

So the defence has to live at the CLI boundary.

Proposed fix

  1. Extend the strip to the SS3 shape \x1bO[A-Za-z~] — a small regex change in all three copies.
  2. Add a post-sanitise floor: require the cleaned name to contain at least one alphanumeric that did not come from an escape final byte. Simplest safe form: if the cleaned string differs from the raw input and the remainder is under N characters, reject or auto-name.
  3. Add the SS3 case to both test files.

Keep it in sanitizeClientName and its two peers, not in slug.gointernal/slug must stay in lock-step with backend/common/utils/slug.py (stated at slug.go's doc comment and sanitize.go:22-26: hygiene at ingestion, never in the shared slug rule). That design is right; it just had no ingestion filter until 2026-07-21, and the filter it got is incomplete.

The structural point

One rule, three hand-maintained copies in three languages, with no shared fixture between them. That is why the SS3 case is missing from all three at once rather than from one. Worth a shared test corpus that each implementation is run against, so the next escape family is a one-line addition rather than three independent misses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions