Why Non-ASCII Input Breaks over SSH: Locales, SendEnv and the `env` Request

Part 4 of "Building an SSH Terminal". Code excerpts are from MistTerm v1.2.26, an open-source (AGPL) SSH terminal client by MistLab; they are trimmed, and source comments are translated from Chinese.

TL;DR

Garbled non-ASCII input over SSH usually means the remote program runs in the POSIX/C locale, so line editors such as readline treat each byte as a character. The locale reaches the server through the RFC 4254 env request, which sshd accepts only for variables listed in AcceptEnv. Upstream OpenSSH sends no variables by default; Debian-family packages add SendEnv LANG LC_* and the matching AcceptEnv. The fix is to forward a valid UTF-8 locale and to allow it on the server.

A common SSH puzzle goes like this. Output in Chinese, Japanese or with accented letters displays fine. But when you type such characters at a bash or mysql prompt, the line fills with garbage, or Backspace deletes half a character. The terminal is not at fault. The server-side program is running in the wrong locale, and the fix is a little-known part of the SSH protocol.

Why does non-ASCII input break over SSH? Bytes are only text inside a locale

Part 1 established that the terminal and the remote program exchange bytes. Turning bytes into characters is the program's job, and C programs do it through the C library's locale. After setlocale(LC_ALL, ""), the LC_CTYPE category, chosen from the environment variables LC_ALL, LC_CTYPE and LANG in that order of precedence, decides the character encoding. Under en_US.UTF-8, the three bytes E4 B8 AD are one character, 中, occupying two columns. Under the C/POSIX locale they are three unrelated bytes.

Output usually survives a wrong locale because most programs pass bytes through unchanged, and the terminal decodes UTF-8 itself. Line editors do not. Readline (bash) and libedit (mysql, sqlite3 on some systems) have to know where each character starts in order to echo, move the cursor, redraw and delete. In the C locale, they treat every byte as a separate character. Depending on configuration (input-meta, output-meta), the bytes are echoed as octal escapes or treated as Meta-modified keys, and Backspace removes one byte of a multi-byte character. That is the garbage you see.

Where the remote locale comes from

An SSH session's environment is assembled on the server. sshd sets a few variables itself, PAM modules may add more (on Debian and Ubuntu, pam_env in /etc/pam.d/sshd reads /etc/default/locale), and the login shell's profile scripts can set more. Minimal images, containers and some distributions end up with no LANG at all, which means the POSIX locale.

The client can also contribute, through the env channel request in RFC 4254 §6.4:

byte      SSH_MSG_CHANNEL_REQUEST
uint32    recipient channel
string    "env"
boolean   want reply
string    variable name
string    variable value

The RFC warns that passing arbitrary variables is a security risk and that servers should restrict them. OpenSSH's sshd accepts nothing by default. Variables have to be listed with AcceptEnv in sshd_config.

Locale forwarding: env requests are sent between pty-req and shell
Locale forwarding: env requests are sent between pty-req and shell

Does ssh send your locale by default?

It is widely believed that the ssh client "sends your locale". Strictly, that is a distribution default, not an OpenSSH default. The upstream ssh_config(5) manual says of SendEnv: "The default is not to send any environment variables." The one exception is TERM, which is not sent through env at all; it travels inside pty-req.

What most users see comes from distribution packaging. Debian's OpenSSH package, and therefore Ubuntu's, ships /etc/ssh/ssh_config with

SendEnv LANG LC_* COLORTERM NO_COLOR

and /etc/ssh/sshd_config with the matching

AcceptEnv LANG LC_* COLORTERM NO_COLOR

Other distributions ship similar pairs. So between two stock Debian-family machines the locale does travel, and that is the behavior a third-party client should reproduce. A client that sends nothing will behave worse than ssh on exactly the servers where AcceptEnv is configured.

(MistTerm's own source comment describes this as "the OpenSSH default". That is imprecise in the way described above, and we list it in the review notes for this series.)

Forwarding the locale: the details

MistTerm sends env requests after pty-req and before shell. Order matters, because once the shell has started, its environment is fixed. Which variables to send, and what to do when there are none, is decided in remote_locale_env:

// src/ssh/client.rs
pub(crate) fn remote_locale_env(get: impl Fn(&str) -> Option<String>) -> Vec<(String, String)> {
    const NAMES: &[&str] = &[
        "LANG", "LC_ALL", "LC_CTYPE", "LC_COLLATE",
        "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME",
    ];
    fn plausible(v: &str) -> bool {
        // macOS Terminal often sets `LC_CTYPE=UTF-8`. That is a macOS-only form that
        // glibc does not accept; sending it makes the remote setlocale fail as a
        // whole and fall back to POSIX, so treat it as unset.
        let bare_codeset = v.eq_ignore_ascii_case("UTF-8") || v.eq_ignore_ascii_case("UTF8");
        !bare_codeset
            && !v.is_empty()
            && v.len() <= 64
            && v.chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@'))
    }
    let mut out: Vec<(String, String)> = NAMES.iter().filter_map(|k| {
        let v = get(k)?;
        let v = v.trim();
        plausible(v).then(|| (k.to_string(), v.to_string()))
    }).collect();
    let has_ctype_source = out.iter()
        .any(|(k, _)| k == "LANG" || k == "LC_ALL" || k == "LC_CTYPE");
    if !has_ctype_source {
        out.insert(0, ("LANG".to_string(), DEFAULT_REMOTE_LANG.to_string())); // "en_US.UTF-8"
    }
    out
}

Three cases drive the design:

  1. Values that only make sense locally. macOS commonly exports LC_CTYPE=UTF-8, a bare codeset that the macOS C library accepts. glibc does not. With setlocale(LC_ALL, ""), one invalid category makes the whole call fail and the program stays in the C locale. Forwarding the variable would cause exactly the problem it was meant to fix, so bare UTF-8/UTF8 values are dropped.
  2. Values that are not locale names. Variables are user-controlled, so only short values made of the characters that appear in locale names ([A-Za-z0-9._@-], 64 bytes at most) are sent.
  3. No locale at all. A Windows desktop normally has no LANG, and neither does a GUI app launched without a login environment. If none of LANG, LC_ALL or LC_CTYPE survives, the client sends LANG=en_US.UTF-8, so that an otherwise unconfigured server still gets a UTF-8 LC_CTYPE.

Each request is sent independently, and a rejection is not an error:

for (name, value) in remote_locale_env(|k| std::env::var(k).ok()) {
    match channel.setenv(&name, &value) {
        Ok(()) => log::info!("SSH setenv {}={}", name, value),
        Err(e) => log::info!("SSH setenv {} rejected by server: {}", name, e),
    }
}

How to fix it: what the client can do, and what the server must allow

Forwarding only helps if the server cooperates in two ways:

When you debug a server, locale inside the session shows what the remote programs actually got.

For MistTerm's local shell tab (a PTY on your own machine, no SSH involved), the same concern is handled by setting the environment directly: TERM=xterm-256color and COLORTERM=truecolor always, and on macOS LANG=en_US.UTF-8 when neither LANG nor LC_ALL is set. An app launched from Finder or the Dock does not inherit a login environment, which is the case this covers.

Summary

FAQ

Why does typed Chinese or accented text turn into garbage over SSH?

The remote program is usually running in the POSIX/C locale, so line editors such as readline handle input byte by byte instead of character by character.

Does ssh send LANG to the server by default?

Upstream OpenSSH sends no environment variables by default. Debian and Ubuntu packages configure SendEnv LANG LC_* in ssh_config and the matching AcceptEnv in sshd_config.

How do I fix garbled input on the server side?

Allow the variables with AcceptEnv LANG LC_* in sshd_config, or set a system default locale, and make sure the locale exists on the server (check with locale -a).

Why should a client not forward LC_CTYPE=UTF-8 from macOS?

glibc does not accept a bare codeset as a locale name. One invalid category makes setlocale(LC_ALL, "") fail, and the program stays in the C locale.

Related in this series

← Part 3 · Series index · Part 5 →