| commit 4e35adf9138371b524fc4c2b36c391b22f783228 parent 0440983d41b1024edc5548c56e0f093cbd7d9a52 Author: sqzer <110329244+sqzer-x@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:39:15 +0900 Security review: sanitise attacker-controlled text, tighten the container Findings came from probing the running server from outside and from attacking the client with hostile links. Terminal escape injection. Values taken from a share link were quoted back in error messages unmodified, so a crafted link painted escape sequences straight onto the operator's terminal -- confirmed with a payload that rendered as coloured text in the failure message. Every untrusted value now passes through a filter that strips non-printable characters and caps the length at 64, on both sides. A 5000-character field now produces a 59-byte message. Container hardening. The process already held zero effective capabilities, but the bounding set was full and no-new-privileges was off, so a future change could have quietly re-acquired privileges. The compose file now drops ALL capabilities, sets no-new-privileges, mounts the root filesystem read-only and gives /tmp a small noexec tmpfs. Verified on a live server: CapEff and CapBnd both 0000000000000000, NoNewPrivs 1, writes to / rejected, and the server still boots, verifies and adds users normally. What the probing found intact, for the record. From outside, the server is indistinguishable from the site it borrows: identical certificate, identical TLS 1.3 and TLS 1.2 behaviour, identical handshake failure on a wrong SNI, identical silence on non-TLS input, identical ALPN, and a completed session returns the real site's HTTP 200 byte for byte. Only ports 22 and 443 are reachable; the loopback SOCKS inbound the healthcheck uses is not. An authenticated user cannot reach cloud metadata over IPv4 or its IPv6 alias, the server's own loopback, any RFC1918 or CGNAT range, or port 25 -- over TCP or UDP -- while ordinary traffic and port 587 work. Diffstat:
|
| 5 files changed, 37 insertion(+), 14 deletion(-) |
diff --git a/README.md b/README.md @@ -65,6 +65,10 @@ services: ports: ["443:8443"] volumes: ["transtation-data:/data"] env_file: .env + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + read_only: true + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"] logging: driver: json-file options: { max-size: "10m", max-file: "3" } diff --git a/client/transtation-client b/client/transtation-client @@ -17,6 +17,10 @@ TUN_ADDR=172.19.0.1/30 SOCKS_PORT=${SOCKS_PORT:-1080} die() { printf 'transtation-client: %s\n' "$*" >&2; exit 1; } +# Anything taken from a share link and quoted back at the operator goes through +# this first. A link is attacker-supplied, error messages land on a terminal, +# and raw escape sequences let a crafted link paint whatever it likes there. +q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; } warn() { printf 'transtation-client: %s\n' "$*" >&2; } # --------------------------------------------------------------- share link @@ -77,11 +81,11 @@ validate() { die "bad pbk: expected 43 characters, got ${#PBK}. Reissue the link with 'transtation show'." printf '%s' "$PBK" | grep -Eq '^[A-Za-z0-9_-]{43}$' || die "pbk is not base64url" printf '%s' "$SID" | grep -Eq '^([0-9a-fA-F]{2}){1,8}$' || - die "bad sid: expected 2-16 hex characters, got '$SID'" - printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || die "bad or missing sni" + die "bad sid: expected 2-16 hex characters, got '$(q "$SID")'" + printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || die "bad or missing sni: '$(q "$SNI")'" [ -n "$HOST" ] || die "link has no server address" printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || - die "bad port: $PORT" + die "bad port: $(q "$PORT")" # sing-box hard-errors with "uTLS is required by reality client" if the # fingerprint is absent, where Xray-based clients would tolerate it. FP=${FP:-chrome} @@ -94,22 +98,22 @@ validate() { # certificate checking switched off. case "$FLOW" in xtls-rprx-vision | none | '') ;; - *) die "unsupported flow in link: $FLOW (expected xtls-rprx-vision)" ;; + *) die "unsupported flow in link: $(q "$FLOW") (expected xtls-rprx-vision)" ;; esac [ "$FLOW" = none ] && FLOW="" case "$FP" in chrome | firefox | edge | safari | ios | android | random | randomized | 360 | qq) ;; - *) die "unsupported uTLS fingerprint in link: $FP" ;; + *) die "unsupported uTLS fingerprint in link: $(q "$FP")" ;; esac # Operator-supplied, but the same class of hole and just as cheap to close. printf '%s' "$SOCKS_PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$SOCKS_PORT" -ge 1 ] && [ "$SOCKS_PORT" -le 65535 ] || - die "SOCKS_PORT must be 1-65535, got: $SOCKS_PORT" + die "SOCKS_PORT must be 1-65535, got: $(q "$SOCKS_PORT")" if [ -n "${DIRECT_SUFFIXES:-}" ]; then for _d in $(printf '%s' "$DIRECT_SUFFIXES" | tr ',' ' '); do printf '%s' "$_d" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || - die "DIRECT_SUFFIXES entry is not a domain suffix: $_d" + die "DIRECT_SUFFIXES entry is not a domain suffix: $(q "$_d")" done fi @@ -119,7 +123,7 @@ validate() { case "$HOST" in *[!0-9.]*) _ip=$(resolve4 "$HOST") || true - [ -n "$_ip" ] || die "cannot resolve $HOST from inside the container" + [ -n "$_ip" ] || die "cannot resolve $(q "$HOST") from inside the container" printf 'resolved %s -> %s\n' "$HOST" "$_ip" SERVER_IP=$_ip ;; diff --git a/install.sh b/install.sh @@ -139,6 +139,13 @@ install_server() { ports: ["\${PORT:-443}:8443"] volumes: ["transtation-data:/data"] env_file: .env + # Hardening. The process already holds zero effective capabilities + # (verified: CapEff 0000000000000000), so these mostly shrink what a + # future change could quietly re-acquire. + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + read_only: true + tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"] logging: driver: json-file options: { max-size: "10m", max-file: "3" } diff --git a/server/transtation b/server/transtation @@ -31,6 +31,8 @@ set_paths die() { printf 'transtation: %s\n' "$*" >&2; exit 1; } warn() { printf 'transtation: %s\n' "$*" >&2; } +# Values echoed back at the operator are stripped of control characters first. +q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; } # State files are PARSED, never `.`-sourced: the values include free text such # as "best of KR-region candidates", and sourcing that runs `of` as a command. @@ -586,7 +588,7 @@ sni_auto() { sni_write() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' || - die "not a hostname: $1" + die "not a hostname: $(q "$1")" case "$1" in *apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW." ;; esac @@ -941,21 +943,21 @@ cmd_selftest() { # one line. Kept as its own function so the selftest can attack it directly. validate_config_env() { printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' || - die "SNI must be a hostname, got: $SNI" + die "SNI must be a hostname, got: $(q "$SNI")" case "$SNI" in *apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW. Pick another SNI." ;; esac DEST=${DEST:-$SNI:443} - printf '%s' "$DEST" | grep -Eq '^[A-Za-z0-9.-]+:[0-9]{1,5}$' || die "DEST must be host:port, got: $DEST" + printf '%s' "$DEST" | grep -Eq '^[A-Za-z0-9.-]+:[0-9]{1,5}$' || die "DEST must be host:port, got: $(q "$DEST")" PORT=${PORT:-443} printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || - die "PORT must be 1-65535, got: $PORT" + die "PORT must be 1-65535, got: $(q "$PORT")" printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' || - die "MIN_CLIENT_VER must look like 0.0.0, got: ${MIN_CLIENT_VER:-}" + die "MIN_CLIENT_VER must look like 0.0.0, got: $(q "${MIN_CLIENT_VER:-}")" # Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint # is exactly what produced the random IPv6 failures this design removed. printf '%s' "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]{1,5}$' || - die "WARP_ENDPOINT must be an IPv4 address and port, got: ${WARP_ENDPOINT:-}" + die "WARP_ENDPOINT must be an IPv4 address and port, got: $(q "${WARP_ENDPOINT:-}")" } # --------------------------------------------------------------------- run diff --git a/test.sh b/test.sh @@ -56,6 +56,12 @@ present_in_compose() { server_compose | grep -qE "$1"; } check "the compose block caps the log size (xray logs to stdout)" present_in_compose 'max-size' check "the compose block uses a named volume, not a bind mount" present_in_compose 'transtation-data:/data' check "no script ever runs 'nft flush ruleset'" uncommented_absent 'flush ruleset' host/transtation-killswitch +# Hardening the compose file grants. The running process was verified to hold +# CapEff 0000000000000000 with these in place. +check "the compose block drops all capabilities" present_in_compose "cap_drop" +check "the compose block sets no-new-privileges" present_in_compose "no-new-privileges" +check "the compose block mounts the root filesystem read-only" present_in_compose "read_only" + if ! command -v docker >/dev/null 2>&1; then echo |