Skip to content

Comments

feat(ssh): backend SSH connector (safe defaults)#79

Merged
heidi-dang merged 7 commits intomainfrom
feat/ssh-connector
Feb 17, 2026
Merged

feat(ssh): backend SSH connector (safe defaults)#79
heidi-dang merged 7 commits intomainfrom
feat/ssh-connector

Conversation

@heidi-dang
Copy link
Owner

Summary

  • Adds backend SSH connector with safe defaults: OFF by default, local-only bind, allowlist deny-by-default, append-only audited exec with secret redaction.

Key Changes

  • New module: src/heidi_cli/connectors/ssh/*
  • Server integration: src/heidi_cli/server.py mounts SSH routes when enabled
  • Tests: tests/test_ssh_connector.py (23 tests)
  • Docs: docs/SSH_CONNECTOR.md

API

  • POST /api/connect/ssh/sessions
  • POST /api/connect/ssh/sessions/{id}/exec
  • DELETE /api/connect/ssh/sessions/{id}
  • GET /api/connect/ssh/sessions
  • GET /api/connect/ssh/config

Security Defaults

  • Disabled unless HEIDI_SSH_ENABLED=1
  • Local-only bind by default (127.0.0.1)
  • Allowlist required (deny by default)
  • Audit log append-only with secret redaction
  • Auth enforced when HEIDI_API_KEY is set (X-Heidi-Key; stream fallback reserved for later)

How to Test

  1. python -m pytest -q tests/test_ssh_connector.py
  2. HEIDI_SSH_ENABLED=1 HEIDI_SSH_TARGETS=localhost heidi serve
  3. Verify 404 when disabled; 403 for non-allowlisted targets; 401 without key when API key set

@gemini-code-assist
Copy link

Summary of Changes

Hello @heidi-dang, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a new SSH connector to the Heidi CLI backend, providing a secure and audited way to interact with remote SSH targets. The connector is designed with strong security defaults, such as being disabled by default, binding only to localhost, and requiring an explicit allowlist for target hosts. It also includes robust audit logging with secret redaction, ensuring all operations are recorded safely. This feature significantly enhances the CLI's capabilities for secure remote execution.

Highlights

  • New SSH Connector Module: Introduced a new backend SSH connector module under src/heidi_cli/connectors/ssh/ to enable secure remote command execution.
  • Secure Defaults Implementation: Implemented strong security defaults for the SSH connector, including being disabled by default, binding only to localhost, and enforcing a deny-by-default target allowlist.
  • Comprehensive Audit Logging: Added robust, append-only audit logging for all SSH operations, featuring automatic secret redaction to protect sensitive information.
  • API Endpoint Integration: Integrated new FastAPI routes for managing SSH sessions (create, execute, close, list) and retrieving connector configuration, with authentication enforcement.
  • Dedicated Documentation: Provided a detailed documentation file (docs/SSH_CONNECTOR.md) outlining the connector's security model, configuration, API, and usage.
  • Extensive Test Coverage: Developed a comprehensive suite of unit and integration tests (tests/test_ssh_connector.py) to ensure the reliability and security of the SSH connector.
Changelog
  • docs/SSH_CONNECTOR.md
    • Documented the new SSH connector, covering its overview, security model, configuration, API endpoints, audit logging, usage examples, security checklist, deployment warnings, troubleshooting, and future enhancements.
  • src/heidi_cli/connectors/ssh/init.py
    • Initialized the SSH connector package, exposing core modules like config, session, connector, and routes.
  • src/heidi_cli/connectors/ssh/audit.py
    • Implemented an SSHAuditLogger class for append-only logging of SSH events (session creation, command execution, closure, errors, blocked targets) with a redact_secrets utility function.
  • src/heidi_cli/connectors/ssh/config.py
    • Defined environment variable-based configuration for the SSH connector, including enablement, bind address, target allowlist, session limits, output size, and audit log path, along with validation functions.
  • src/heidi_cli/connectors/ssh/connector.py
    • Provided the core logic for SSH operations, including exec_command (currently a local subprocess mock), create_session, close_session, and get_session, with target validation.
  • src/heidi_cli/connectors/ssh/routes.py
    • Created FastAPI endpoints for the SSH connector, handling session creation, command execution, session closure, session listing, and configuration retrieval, incorporating authentication and security checks.
  • src/heidi_cli/connectors/ssh/session.py
    • Implemented SSHSession and SSHSessionManager classes for in-memory management of SSH sessions, handling creation, retrieval, expiration, and activity tracking.
  • src/heidi_cli/server.py
    • Modified the main FastAPI application to lazily import and conditionally include the SSH connector's routes based on its availability.
  • tests/test_ssh_connector.py
    • Added a comprehensive test suite covering feature flag behavior, target allowlist, session management, audit logging (including secret redaction), connector core functions, and basic API route interactions.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new SSH connector, which is a significant feature. While it incorporates good security practices like being disabled by default, using an allowlist, and having audit logs, it contains critical security vulnerabilities. Specifically, there's a Remote Command Injection vulnerability due to the use of shell=True with user-controlled input in the exec_command function, even in its mock implementation, which could be enabled in production. Additionally, the target allowlist logic is too permissive, and there's a lack of proper session ownership checks, which could lead to unauthorized access in a multi-user environment. There are also a few bugs and areas for improvement related to request handling and configuration reporting. These critical issues must be addressed to ensure the connector's security and robustness before merging.

Comment on lines 113 to 115
result = subprocess.run(
command,
shell=True,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The exec_command function uses subprocess.run with shell=True and user-provided input (command), which is a critical Remote Command Injection vulnerability. Even though it's currently a mock implementation, this code is executable and can be enabled in production, allowing an attacker with API access to execute arbitrary commands on the host system. To fix this, set shell=False and safely parse the command string into a list using shlex.split().

Comment on lines +63 to +88
def is_target_allowed(target: str) -> bool:
"""Check if target is in allowlist.

Args:
target: Target string like "host:port" or "host"

Returns:
True if target is allowed, False otherwise
"""
if not SSH_TARGET_ALLOWLIST:
return False # Deny by default if no allowlist

# Normalize target
if ":" not in target:
target = f"{target}:22"

# Check exact match
if target in SSH_TARGET_ALLOWLIST:
return True

# Check host-only match (port 22 default)
host = target.split(":")[0]
if host in SSH_TARGET_ALLOWLIST:
return True

return False

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of is_target_allowed has a security vulnerability. It allows connections to any port on a host if the host name alone is in the allowlist. For example, if HEIDI_SSH_TARGETS is set to "localhost", a user can connect to localhost:2222 or any other port, which is likely not the intended behavior. The logic should be stricter to only allow specified host:port combinations or hosts with the default port 22.

I suggest refactoring the function to be more explicit about what is allowed. Also, please add a test case to cover this scenario, for example, checking that is_target_allowed("localhost:2222") returns False when SSH_TARGET_ALLOWLIST is {"localhost"}.

def is_target_allowed(target: str) -> bool:
    """Check if target is in allowlist.

    Args:
        target: Target string like "host:port" or "host"

    Returns:
        True if target is allowed, False otherwise
    """
    if not SSH_TARGET_ALLOWLIST:
        return False  # Deny by default if no allowlist

    # Check for an exact match, which could be "host" or "host:port".
    if target in SSH_TARGET_ALLOWLIST:
        return True

    # If the user provided a target without a port, check if the version
    # with the default port 22 is in the allowlist.
    if ":" not in target:
        if f"{target}:22" in SSH_TARGET_ALLOWLIST:
            return True

    return False

Comment on lines +146 to +148
target = body.target
if body.port and body.port != 22:
target = f"{body.target}:{body.port}"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for constructing the target string is buggy. If a user provides a target with a port in body.target (e.g., "localhost:1234") and also provides a body.port (e.g., 5678), the resulting target string will be invalid ("localhost:1234:5678").

You should handle these cases more robustly, for example by raising an error if both are provided, or by giving one precedence over the other.

Suggested change
target = body.target
if body.port and body.port != 22:
target = f"{body.target}:{body.port}"
target = body.target
if ":" in target and body.port is not None and body.port != 22:
raise HTTPException(
status_code=400,
detail="Cannot specify a port in the target string and a different one in the 'port' field.",
)
if ":" not in target and body.port is not None:
target = f"{target}:{body.port}"

)


@router.post("/sessions/{session_id}/exec", response_model=ExecResponse)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The exec_ssh_command endpoint does not verify that the authenticated user is the owner of the session they are accessing. The verify_auth dependency returns a generic "authenticated" string for any user with a valid API key, and it does not integrate with the request.state.user set by the AuthMiddleware for OAuth-authenticated users. This allows any authenticated user to interact with SSH sessions created by other users if they can obtain the session_id (which is a UUID, but still represents a broken access control).



@router.delete("/sessions/{session_id}", status_code=204)
async def close_ssh_session(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Similar to the exec_ssh_command endpoint, the close_ssh_session endpoint lacks an ownership check, allowing any authenticated user to close sessions belonging to others.

Comment on lines +206 to +218
def get_audit_logger(log_path: Path) -> SSHAuditLogger:
"""Get or create the global audit logger instance.

Args:
log_path: Path to audit log file

Returns:
SSHAuditLogger instance
"""
global _audit_logger
if _audit_logger is None:
_audit_logger = SSHAuditLogger(log_path)
return _audit_logger

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The get_audit_logger function is implemented as a singleton, but its signature get_audit_logger(log_path: Path) is misleading. If the function is called with different paths, only the path from the first call will be used to initialize the logger.
To make this less error-prone, I recommend refactoring it to not take any arguments and instead fetch the log path directly from the configuration. This will make its behavior as a singleton clearer.

You will then need to update the calls to this function in src/heidi_cli/connectors/ssh/routes.py to get_audit_logger().

Suggested change
def get_audit_logger(log_path: Path) -> SSHAuditLogger:
"""Get or create the global audit logger instance.
Args:
log_path: Path to audit log file
Returns:
SSHAuditLogger instance
"""
global _audit_logger
if _audit_logger is None:
_audit_logger = SSHAuditLogger(log_path)
return _audit_logger
def get_audit_logger() -> SSHAuditLogger:
"""Get or create the global audit logger instance.
Returns:
SSHAuditLogger instance
"""
from .config import SSH_AUDIT_LOG_PATH
global _audit_logger
if _audit_logger is None:
_audit_logger = SSHAuditLogger(SSH_AUDIT_LOG_PATH)
return _audit_logger

return {
"enabled": is_ssh_enabled(),
"bind_address": os.getenv("HEIDI_SSH_BIND", "127.0.0.1"),
"target_allowlist_count": len(os.getenv("HEIDI_SSH_TARGETS", "").split(",")),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The calculation for target_allowlist_count is incorrect. os.getenv("HEIDI_SSH_TARGETS", "").split(",") will result in a list with one empty string (['']) if the environment variable is not set, leading to a count of 1 instead of 0.

You should use the already parsed SSH_TARGET_ALLOWLIST set from the config module to get the correct count. You will need to import SSH_TARGET_ALLOWLIST from .config.

Suggested change
"target_allowlist_count": len(os.getenv("HEIDI_SSH_TARGETS", "").split(",")),
"target_allowlist_count": len(SSH_TARGET_ALLOWLIST),

- Remove shell=True - exec_command now returns NOT_IMPLEMENTED stub
- Fix tests to use monkeypatch instead of global env at import time
- Add PTY streaming endpoints (Phase 2 stubs)
- Fix lint errors
@heidi-dang heidi-dang merged commit 2f50e9e into main Feb 17, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant