From 2012504616f802c41a8cbc845391a0f20b1c9f6e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Fri, 28 Nov 2025 16:20:18 -0800 Subject: [PATCH] - trim container image size - sanitize socktop inputs --- .gitignore | 1 + Dockerfile | 186 +++++++++++++++++---------------- README.md | 68 ++++++++++++ docker-compose.yml | 12 ++- docker/restricted-shell.sh | 28 ++++- files/README.md | 37 ++++++- scripts/test-shell-security.sh | 152 +++++++++++++++++++++++++++ 7 files changed, 385 insertions(+), 99 deletions(-) create mode 100755 scripts/test-shell-security.sh diff --git a/.gitignore b/.gitignore index ea16ed7..be80640 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ scripts/publish-to-gitea-multiarch.sh scripts/publish-to-gitea.sh scripts/verify_upgrade.sh scripts/check-setup.sh +scripts/test-docker-config.sh diff --git a/Dockerfile b/Dockerfile index d234ba2..4c83218 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,127 +1,129 @@ -# Dockerfile for socktop webterm -# Based on Debian Trixie Slim with all required dependencies +# Multi-stage Dockerfile for socktop webterm +# This reduces the final image size significantly by separating build and runtime -FROM debian:trixie-slim +# ============================================================================ +# Stage 1: Rust Builder +# ============================================================================ +FROM rust:1.90-slim-bookworm AS rust-builder -# Avoid prompts from apt -ENV DEBIAN_FRONTEND=noninteractive +WORKDIR /build -# Set environment variables -ENV RUST_VERSION=stable -ENV CARGO_HOME=/usr/local/cargo -ENV RUSTUP_HOME=/usr/local/rustup -ENV PATH=/usr/local/cargo/bin:$PATH -ENV TERM=xterm-256color - -# Install system dependencies and security updates +# Install build dependencies RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y \ - # Build dependencies - build-essential \ + apt-get install -y --no-install-recommends \ pkg-config \ libssl-dev \ - # Rust/Cargo (needed to build webterm) - curl \ - ca-certificates \ - # Node.js and npm (for xterm.js) - nodejs \ - npm \ - # Alacritty dependencies - cmake \ - fontconfig \ - libfontconfig1-dev \ - libfreetype6-dev \ - libxcb-xfixes0-dev \ - libxkbcommon-dev \ - python3 \ - # Runtime dependencies - fonts-liberation \ - gnupg2 \ - wget \ - unzip \ - git \ - # Process management - supervisor \ && rm -rf /var/lib/apt/lists/* -# Install Rust -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --default-toolchain ${RUST_VERSION} --profile minimal && \ - chmod -R a+w ${RUSTUP_HOME} ${CARGO_HOME} +# Copy only dependency files first for better caching +COPY Cargo.toml Cargo.lock ./ -# Install Alacritty -RUN cargo install alacritty && \ - rm -rf ${CARGO_HOME}/registry ${CARGO_HOME}/git +# Create dummy source to cache dependencies +RUN mkdir src && \ + echo "fn main() {}" > src/server.rs && \ + echo "pub fn lib() {}" > src/lib.rs && \ + cargo build --release && \ + rm -rf src -# Download and install FiraCode Nerd Font -RUN mkdir -p /usr/share/fonts/truetype/firacode-nerd && \ - cd /tmp && \ - wget -q https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/FiraCode.zip && \ - unzip -q FiraCode.zip -d /usr/share/fonts/truetype/firacode-nerd/ && \ - rm FiraCode.zip && \ - fc-cache -fv && \ - rm -rf /var/lib/apt/lists/* +# Copy actual source code +COPY src ./src +COPY templates ./templates +COPY static ./static -# Add socktop APT repository with GPG key -RUN curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \ - gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" > /etc/apt/sources.list.d/socktop.list && \ - apt-get update && \ - apt-get install -y socktop socktop-agent && \ - rm -rf /var/lib/apt/lists/* - -# Create application user (if not already exists from package) -RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \ - mkdir -p /home/socktop/.config/alacritty && \ - mkdir -p /home/socktop/.config/socktop && \ - chown -R socktop:socktop /home/socktop - -# Set working directory -WORKDIR /app - -# Copy application files -COPY --chown=socktop:socktop Cargo.toml Cargo.lock ./ -COPY --chown=socktop:socktop src ./src -COPY --chown=socktop:socktop templates ./templates -COPY --chown=socktop:socktop static ./static -COPY --chown=socktop:socktop package.json package-lock.json ./ - -# Build the Rust application +# Build the actual application RUN cargo build --release && \ - rm -rf target/release/build target/release/deps target/release/incremental && \ strip target/release/webterm-server -# Install npm dependencies and copy static files +# ============================================================================ +# Stage 2: Node.js Builder +# ============================================================================ +FROM node:20-slim AS node-builder + +WORKDIR /build + +# Copy package files +COPY package.json package-lock.json ./ +COPY static ./static + +# Install only production dependencies RUN npm ci --only=production && \ + # Copy static files to node_modules for serving cp static/terminado-addon.js node_modules/ && \ cp static/bg.png node_modules/ && \ cp static/styles.css node_modules/ && \ cp static/terminal.js node_modules/ && \ cp static/favicon.png node_modules/ -# Copy configuration files from /files directory (will be mounted as volume) -# This will be done at runtime via entrypoint script +# ============================================================================ +# Stage 3: Runtime Image +# ============================================================================ +FROM debian:trixie-slim -# Copy supervisor configuration -COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +# Avoid prompts from apt +ENV DEBIAN_FRONTEND=noninteractive +ENV TERM=xterm-256color -# Copy entrypoint and restricted shell scripts +# Install only runtime dependencies +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + # Runtime libraries + libssl3 \ + ca-certificates \ + # For socktop packages + curl \ + gnupg2 \ + # Shell and utilities + bash \ + procps \ + # Health check + curl \ + && rm -rf /var/lib/apt/lists/* + +# Add socktop APT repository and install packages +RUN curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \ + gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" > /etc/apt/sources.list.d/socktop.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends socktop socktop-agent && \ + rm -rf /var/lib/apt/lists/* + +# Create application user (if not already exists from socktop packages) +RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \ + mkdir -p /home/socktop/.config/socktop && \ + chown -R socktop:socktop /home/socktop + +# Set working directory +WORKDIR /app + +# Copy built binary from rust-builder +COPY --from=rust-builder /build/target/release/webterm-server /usr/local/bin/webterm-server + +# Copy templates and static files +COPY --from=rust-builder /build/templates ./templates +COPY --from=rust-builder /build/static ./static + +# Copy node_modules from node-builder +COPY --from=node-builder /build/node_modules ./node_modules + +# Copy runtime scripts COPY docker/entrypoint.sh /entrypoint.sh -COPY docker/restricted-shell.sh /usr/local/bin/restricted-shell -RUN chmod +x /entrypoint.sh && chmod +x /usr/local/bin/restricted-shell +RUN chmod +x /entrypoint.sh # Expose ports # 8082 - webterm HTTP server -# 3001 - socktop agent +# 3001 - socktop agent (if used) EXPOSE 8082 3001 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8082/ || exit 1 -# Set entrypoint (runs as root, then switches to socktop user) +# Run as socktop user +USER socktop + +# Set entrypoint ENTRYPOINT ["/entrypoint.sh"] -# Default command (can be overridden) -CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] +# Default command - run webterm server +CMD ["webterm-server", "--host", "0.0.0.0", "--port", "8082"] diff --git a/README.md b/README.md index c932bb7..72d3d3c 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,74 @@ This project includes a complete CI/CD pipeline using Gitea Actions: - **Containerization**: Docker, Kubernetes/k3s - **CI/CD**: Gitea Actions +## Security and Limitations + +### Security Model + +This application is designed to provide **safe, public terminal access** for demonstration purposes. The security model consists of multiple layers: + +#### 1. Restricted Shell + +When deployed in production (e.g., https://www.socktop.io), the application uses a restricted shell (`docker/restricted-shell.sh`) that: + +- **Allows only 2 commands**: `socktop` and `help` +- **Blocks all other commands**: Rejects any attempt to run `ls`, `cat`, `bash`, etc. +- **Validates arguments**: All arguments passed to `socktop` are sanitized to prevent command injection +- **Prevents shell escapes**: Blocks metacharacters like `;`, `&&`, `|`, `$()`, backticks, etc. +- **Blocks path traversal**: Prevents attempts like `../../../etc/passwd` + +**Example validation:** +```bash +# Allowed +socktop -P local +socktop -P rpi-master +socktop ws://192.168.1.100:3000 + +# Blocked with error message +socktop $(whoami) # Command substitution blocked +socktop ; /bin/bash # Shell metacharacter blocked +socktop -P ../etc/passwd # Path traversal blocked +``` + +#### 2. Argument Sanitization + +The restricted shell validates all input using regex patterns: +- Profile names: Only `[a-zA-Z0-9_-]+` characters allowed +- WebSocket URLs: Only `ws://` or `wss://` with safe characters +- No environment variable expansion +- No special characters or shell operators + +**Security testing**: Run `./scripts/test-shell-security.sh` to verify all 35 security checks pass. + +#### 3. Container Isolation + +- Runs inside Docker container (cannot access host system) +- Non-root user (`socktop` user) +- Limited resources (CPU/memory limits configurable) +- No privileged operations +- Read-only configuration mounts + +#### 4. WebSocket Security + +The WebSocket endpoint (`/websocket`) is secure by design: +- Command spawned is **hardcoded at server startup** (via `--command` flag) +- No way to change which shell is spawned via WebSocket connection +- No HTTP headers or request parameters influence the spawned command +- Direct WebSocket connections get the same restricted shell as browser connections + +**The Terminado protocol only supports:** +- `stdin` - Send input to terminal (goes through restricted shell) +- `stdout` - Receive output from terminal +- `set_size` - Resize terminal (validated to u16 row/col numbers only) + +There is no protocol message type that can bypass the shell or execute arbitrary commands. + +### Reporting Security Issues + +If you discover a security vulnerability that bypasses the restricted shell or container isolation, please report it via: +- GetTea Issues (for non-critical issues) +- Direct contact to maintainer (for critical vulnerabilities) + ## Credits This project was originally forked from [webterm](https://github.com/fubarnetes/webterm) by Fabian Freyer. I chose this as a base because other projects were way too complex. His project was simple and easy to understand, but it was not well maintained. I have updated it to modern packages and heavily customized it to display a working demo for socktop. diff --git a/docker-compose.yml b/docker-compose.yml index 57bca73..d134fad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,16 @@ services: network_mode: "host" volumes: - # Mount configuration files from host (read-write so root can access them) - - ./files:/files + # Mount configuration files directly to proper locations + - ./files/alacritty.toml:/home/socktop/.config/alacritty/alacritty.toml:ro + - ./files/catppuccin-frappe.toml:/home/socktop/.config/alacritty/catppuccin-frappe.toml:ro + - ./files/profiles.json:/home/socktop/.config/socktop/profiles.json:ro + + # Mount SSH certificates (optional - comment out if not using) + - ./files/rpi-master.pem:/home/socktop/.config/socktop/certs/rpi-master.pem:ro + - ./files/rpi-worker-1.pem:/home/socktop/.config/socktop/certs/rpi-worker-1.pem:ro + - ./files/rpi-worker-2.pem:/home/socktop/.config/socktop/certs/rpi-worker-2.pem:ro + - ./files/rpi-worker-3.pem:/home/socktop/.config/socktop/certs/rpi-worker-3.pem:ro # Optional: persist socktop data - socktop-data:/home/socktop/.local/share/socktop diff --git a/docker/restricted-shell.sh b/docker/restricted-shell.sh index 9eb8476..6c33a0f 100644 --- a/docker/restricted-shell.sh +++ b/docker/restricted-shell.sh @@ -132,13 +132,35 @@ main() { case "$cmd" in socktop) - # Allow socktop with any arguments + # Allow socktop with validated arguments only if [ "$cmd" = "$input" ]; then # No arguments, use default (local profile) /usr/bin/socktop -P local else - # Pass arguments to socktop - /usr/bin/socktop $args + # Validate and sanitize arguments to prevent command injection + # Only allow: -P or ws:// + + # Check for profile argument (-P followed by safe profile name) + if [[ "$args" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]]; then + # Extract profile name and validate it + profile=$(echo "$args" | sed 's/-P[[:space:]]\+//') + /usr/bin/socktop -P "$profile" + # Check for websocket URL (ws:// or wss://) + elif [[ "$args" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then + # Validate websocket URL format + /usr/bin/socktop "$args" + else + # Reject anything else as potentially dangerous + echo -e "${RED}Error:${NC} Invalid arguments for socktop" + echo -e "${YELLOW}Allowed usage:${NC}" + echo " socktop - Use default local profile" + echo " socktop -P - Use named profile (alphanumeric, dash, underscore only)" + echo " socktop - Connect to websocket URL (ws:// or wss://)" + echo "" + echo -e "${YELLOW}Examples:${NC}" + echo " socktop -P rpi-master" + echo " socktop ws://192.168.1.100:3000" + fi fi ;; help|--help|-h) diff --git a/files/README.md b/files/README.md index 4f782de..80b3bcd 100644 --- a/files/README.md +++ b/files/README.md @@ -2,6 +2,23 @@ This directory contains configuration files that will be mounted into the Docker container at runtime. +## Docker Usage + +When using Docker (via `docker-compose.yml` or `scripts/docker-quickstart.sh`): + +1. **Files are mounted directly** to the proper locations in the container: + - `alacritty.toml` → `/home/socktop/.config/alacritty/alacritty.toml` + - `catppuccin-frappe.toml` → `/home/socktop/.config/alacritty/catppuccin-frappe.toml` + - `profiles.json` → `/home/socktop/.config/socktop/profiles.json` + - `*.pem` certificates → `/home/socktop/.config/socktop/certs/` + +2. **Files are read-only** in the container (mounted with `:ro` flag) + +3. **To update configuration**: + - Edit files in this directory on your host + - Changes are immediately visible in the container (no restart needed for most configs) + - For some changes, restart may be needed: `docker-compose restart` or `scripts/docker-quickstart.sh restart` + ## Required Files Place your actual configuration files in this directory before building/running the container: @@ -25,11 +42,27 @@ Place your actual configuration files in this directory before building/running - Copy from: `profiles.json.example` - Update with your actual host IPs and connection details -### 3. SSH Keys +### 3. SSH Certificates (Optional) **`rpi-master.pem`** - SSH private key for master node -- **IMPORTANT**: Set permissions to 600 +- **Permissions**: Must be `600` (will be auto-fixed by entrypoint) +- Only needed if connecting to remote systems + +**`rpi-worker-1.pem`, `rpi-worker-2.pem`, `rpi-worker-3.pem`** +- SSH private keys for worker nodes +- **Permissions**: Must be `600` +- Optional - add as needed for your systems + +**Note**: If no certificates are provided, the container will still work for local monitoring. + +### 4. Docker-Specific Notes + +- Files are mounted directly from this directory to their final locations in the container +- Files are mounted read-only (`:ro`) for security +- Certificate permissions should be `600` on the host before mounting +- For local testing, you can comment out the certificate mounts in docker-compose.yml +- Without certificates, the container will still work for local monitoring **`rpi-worker-1.pem`** - SSH private key for worker node 1 diff --git a/scripts/test-shell-security.sh b/scripts/test-shell-security.sh new file mode 100755 index 0000000..762d81b --- /dev/null +++ b/scripts/test-shell-security.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# Security test script for restricted shell +# Tests various injection and escape attempts + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Restricted Shell Security Test ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}" +echo "" + +PASSED=0 +FAILED=0 +TOTAL=0 + +# Function to test a command +test_command() { + local test_name="$1" + local test_input="$2" + local should_block="$3" # "block" or "allow" + + TOTAL=$((TOTAL + 1)) + + echo -ne "${YELLOW}Testing:${NC} $test_name ... " + + # Note: This is a template. In practice, you'd need to: + # 1. Send input to the restricted shell + # 2. Check if it was blocked or executed + # 3. Verify no unauthorized commands ran + + # For now, we'll test the regex patterns + if [[ "$should_block" == "block" ]]; then + # These should be blocked + if [[ "$test_input" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]] || \ + [[ "$test_input" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then + echo -e "${RED}FAIL${NC} - Should have blocked but pattern matched" + FAILED=$((FAILED + 1)) + else + echo -e "${GREEN}PASS${NC} - Correctly blocked" + PASSED=$((PASSED + 1)) + fi + else + # These should be allowed + if [[ "$test_input" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]] || \ + [[ "$test_input" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then + echo -e "${GREEN}PASS${NC} - Correctly allowed" + PASSED=$((PASSED + 1)) + else + echo -e "${RED}FAIL${NC} - Should have allowed but pattern didn't match" + FAILED=$((FAILED + 1)) + fi + fi +} + +echo -e "${BLUE}═══ Testing Valid Commands (Should Allow) ═══${NC}" +echo "" + +test_command "Local profile" "-P local" "allow" +test_command "Remote profile" "-P rpi-master" "allow" +test_command "Profile with dash" "-P rpi-worker-1" "allow" +test_command "Profile with underscore" "-P my_profile" "allow" +test_command "Websocket URL" "ws://192.168.1.100:3000" "allow" +test_command "Secure websocket" "wss://example.com:3000" "allow" +test_command "Websocket with path" "ws://192.168.1.100:3000/ws" "allow" + +echo "" +echo -e "${BLUE}═══ Testing Command Injection (Should Block) ═══${NC}" +echo "" + +test_command "Command substitution \$()" "-P \$(whoami)" "block" +test_command "Command substitution backticks" "-P \`id\`" "block" +test_command "Shell semicolon" "-P local; ls -la" "block" +test_command "Shell AND operator" "-P local && cat /etc/passwd" "block" +test_command "Shell OR operator" "-P local || /bin/sh" "block" +test_command "Shell pipe" "-P local | grep root" "block" +test_command "Shell redirect" "-P local > /tmp/output" "block" +test_command "Shell background" "-P local &" "block" + +echo "" +echo -e "${BLUE}═══ Testing Path Traversal (Should Block) ═══${NC}" +echo "" + +test_command "Parent directory" "-P ../etc/passwd" "block" +test_command "Absolute path" "-P /etc/passwd" "block" +test_command "Multiple parent dirs" "-P ../../bin/bash" "block" +test_command "Encoded path" "-P %2e%2e%2f" "block" + +echo "" +echo -e "${BLUE}═══ Testing Special Characters (Should Block) ═══${NC}" +echo "" + +test_command "Newline injection" "-P local\nls" "block" +test_command "Carriage return" "-P local\rls" "block" +test_command "Null byte" "-P local\x00ls" "block" +test_command "Single quote" "-P local' ls" "block" +test_command "Double quote" "-P local\" ls" "block" +test_command "Dollar sign" "-P \$HOME" "block" +test_command "Asterisk wildcard" "-P local*" "block" +test_command "Question wildcard" "-P local?" "block" + +echo "" +echo -e "${BLUE}═══ Testing Environment Variables (Should Block) ═══${NC}" +echo "" + +test_command "HOME variable" "-P \$HOME" "block" +test_command "PATH variable" "-P \$PATH" "block" +test_command "SHELL variable" "-P \$SHELL" "block" +test_command "Braced variable" "-P \${HOME}" "block" + +echo "" +echo -e "${BLUE}═══ Testing WebSocket URL Exploits (Should Block) ═══${NC}" +echo "" + +test_command "WS with command injection" "ws://evil.com/\$(id)" "block" +test_command "WS with backticks" "ws://evil.com/\`whoami\`" "block" +test_command "WS with semicolon" "ws://evil.com/; ls" "block" +test_command "WS with spaces" "ws://evil.com/ /bin/sh" "block" + +echo "" +echo -e "${BLUE}════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} TEST SUMMARY ${NC}" +echo -e "${BLUE}════════════════════════════════════════════════════${NC}" +echo "" +echo -e "Total Tests: ${BLUE}$TOTAL${NC}" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo "" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}✓ All security tests passed!${NC}" + echo "" + echo -e "${YELLOW}Note:${NC} These are pattern validation tests only." + echo "For complete security verification, you should:" + echo " 1. Test in actual container environment" + echo " 2. Verify socktop binary doesn't process malicious args" + echo " 3. Monitor for unexpected process execution" + echo " 4. Check logs for injection attempts" + echo "" + exit 0 +else + echo -e "${RED}✗ Some security tests failed!${NC}" + echo "" + echo "Review the failed tests and update regex patterns in restricted-shell.sh" + echo "" + exit 1 +fi