Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/modules/valkey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Valkey

Since testcontainers-python <a href="https://github.com/testcontainers/testcontainers-python/releases/tag/v4.14.0"><span class="tc-version">:material-tag: v4.14.0</span></a>

## Introduction

The Testcontainers module for Valkey.

## Adding this module to your project dependencies

Please run the following command to add the Valkey module to your python dependencies:

```bash
pip install testcontainers[valkey]
```

## Usage example

<!--codeinclude-->

[Creating a Valkey container](../../modules/valkey/example_basic.py)

<!--/codeinclude-->
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ nav:
- modules/redis.md
- modules/scylla.md
- modules/trino.md
- modules/valkey.md
- modules/weaviate.md
- modules/aws.md
- modules/azurite.md
Expand Down
2 changes: 2 additions & 0 deletions modules/valkey/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. autoclass:: testcontainers.valkey.ValkeyContainer
.. title:: testcontainers.valkey.ValkeyContainer
84 changes: 84 additions & 0 deletions modules/valkey/example_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import socket

from testcontainers.valkey import ValkeyContainer


def basic_example():
with ValkeyContainer() as valkey_container:
# Get connection parameters
host = valkey_container.get_host()
port = valkey_container.get_exposed_port()
connection_url = valkey_container.get_connection_url()

print(f"Valkey connection URL: {connection_url}")
print(f"Host: {host}, Port: {port}")

# Connect using raw socket and RESP protocol
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

Choose a reason for hiding this comment

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

Avoid Raw Socket Usage in Examples

Location: modules/valkey/example_basic.py

Current Approach:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((host, port))
    s.sendall(b"*1\r\n$4\r\nPING\r\n")  # Raw RESP protocol
    response = s.recv(1024)

Issues:

  • Requires understanding of RESP protocol
  • Makes examples harder to follow
  • Not how users would typically interact with Valkey
  • No error handling

Recommendation:

Consider using Valkey-Glide client library for cleaner examples. Kiro should be able to easily convert these examples to Valkey-Glide

Copy link
Owner Author

Choose a reason for hiding this comment

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

changed

s.connect((host, port))

# PING command
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
print(f"PING response: {response.decode()}")

# SET command
s.sendall(b"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n")
response = s.recv(1024)
print(f"SET response: {response.decode()}")

# GET command
s.sendall(b"*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n")
response = s.recv(1024)
print(f"GET response: {response.decode()}")


def password_example():
with ValkeyContainer().with_password("mypassword") as valkey_container:
host = valkey_container.get_host()
port = valkey_container.get_exposed_port()
connection_url = valkey_container.get_connection_url()

print(f"\nValkey with password connection URL: {connection_url}")

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))

# AUTH command
s.sendall(b"*2\r\n$4\r\nAUTH\r\n$10\r\nmypassword\r\n")
response = s.recv(1024)
print(f"AUTH response: {response.decode()}")

# PING after auth
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
print(f"PING response: {response.decode()}")


def version_example():
# Using specific version
with ValkeyContainer().with_image_tag("8.0") as valkey_container:
print(f"\nUsing image: {valkey_container.image}")
connection_url = valkey_container.get_connection_url()
print(f"Connection URL: {connection_url}")


def bundle_example():
# Using bundle with all modules (JSON, Bloom, Search, etc.)
with ValkeyContainer().with_bundle() as valkey_container:
print(f"\nUsing bundle image: {valkey_container.image}")
host = valkey_container.get_host()
port = valkey_container.get_exposed_port()

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
print(f"PING response: {response.decode()}")


if __name__ == "__main__":
basic_example()
password_example()
version_example()
bundle_example()
138 changes: 138 additions & 0 deletions modules/valkey/testcontainers/valkey/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import socket
from typing import Optional

from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_container_is_ready


class ValkeyNotReady(Exception):
pass


class ValkeyContainer(DockerContainer):
"""
Valkey container.

Example:

.. doctest::

>>> from testcontainers.valkey import ValkeyContainer

>>> with ValkeyContainer() as valkey_container:
... connection_url = valkey_container.get_connection_url()
"""

def __init__(self, image: str = "valkey/valkey:latest", port: int = 6379, **kwargs) -> None:
super().__init__(image, **kwargs)
self.port = port
self.password: Optional[str] = None
self.with_exposed_ports(self.port)

def with_password(self, password: str) -> "ValkeyContainer":
"""
Configure authentication for Valkey.

Args:
password: Password for Valkey authentication.

Returns:
self: Container instance for method chaining.
"""
self.password = password
self.with_command(f"valkey-server --requirepass {password}")

Choose a reason for hiding this comment

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

Issue #1: Command Injection Vulnerability in Password Handling

Location: modules/valkey/testcontainers/valkey/__init__.py, line 56

Current Code:

def with_password(self, password: str) -> "ValkeyContainer":
    self.password = password
    self.with_command(f"valkey-server --requirepass {password}")
    return self

Vulnerability Description:

The password is directly interpolated into a shell command without proper escaping. When Docker executes a string command, it passes it through a shell (/bin/sh -c), which interprets special characters before running the command.

Attack Scenarios:

  1. Command Injection:

    container.with_password("secret; rm -rf /data; echo pwned")
    # Executes: valkey-server --requirepass secret; rm -rf /data; echo pwned
    # Result: THREE separate commands executed!
  2. Command Substitution:

    container.with_password("$(cat /etc/passwd)")
    # Executes: valkey-server --requirepass $(cat /etc/passwd)
    # Result: Reads /etc/passwd and uses content as password
  3. Broken Parsing:

    container.with_password("my secret password")
    # Executes: valkey-server --requirepass my secret password
    # Result: Only "my" is used as password, "secret" and "password" are extra args

Security Impact:

  • OWASP Classification: A03:2021 - Injection
  • Severity: HIGH
  • Likelihood: MEDIUM (depends on password source)
  • Potential Impact:
    • Arbitrary command execution within container
    • Data deletion or corruption
    • Container escape attempts
    • Privilege escalation
    • Unauthorized access to container resources

Recommended Fix:

Use list-based command arguments to bypass shell interpretation:

def with_password(self, password: str) -> "ValkeyContainer":
    self.password = password
    self.with_command(["valkey-server", "--requirepass", password])
    return self

Why This Works:

  • List arguments are passed directly to the process without shell parsing
  • Special characters are treated as literal strings
  • Spaces and other characters work correctly
  • Command injection becomes impossible

Comparison:

Method Shell Parsing Special Chars Injection Risk
String (current) ✓ Yes Interpreted ❌ HIGH
List (suggested) ✗ No Literal ✅ SAFE

References:

Copy link
Owner Author

Choose a reason for hiding this comment

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

fixed

return self

def with_image_tag(self, tag: str) -> "ValkeyContainer":
"""
Specify Valkey version.

Args:
tag: Image tag (e.g., '8.0', 'latest', 'bundle:latest').

Returns:
self: Container instance for method chaining.
"""
base_image = self.image.split(":")[0]
self.image = f"{base_image}:{tag}"
return self

def with_bundle(self) -> "ValkeyContainer":
"""
Enable all modules by switching to valkey-bundle image.

Returns:
self: Container instance for method chaining.
"""
self.image = self.image.replace("valkey/valkey", "valkey/valkey-bundle")
return self

def get_connection_url(self) -> str:
"""
Get connection URL for Valkey.

Returns:
url: Connection URL in format valkey://[:password@]host:port
"""
host = self.get_host()
port = self.get_exposed_port()
if self.password:
return f"valkey://:{self.password}@{host}:{port}"
return f"valkey://{host}:{port}"

def get_host(self) -> str:
"""
Get container host.

Returns:
host: Container host IP.
"""
return self.get_container_host_ip()

def get_exposed_port(self) -> int:
"""
Get mapped port.

Returns:
port: Exposed port number.
"""
return int(super().get_exposed_port(self.port))

@wait_container_is_ready(ValkeyNotReady)

Choose a reason for hiding this comment

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

Deprecated Decorator Usage

Location: modules/valkey/testcontainers/valkey/__init__.py, line 113

Current Code:

@wait_container_is_ready(ValkeyNotReady)
def _connect(self) -> None:
    """Wait for Valkey to be ready by sending PING command."""
    # ... connection logic

Issue:

The @wait_container_is_ready decorator is deprecated in the codebase. Recent commits show active migration away from this pattern:

Evidence from Codebase:

# From pytest.ini_options filterwarnings:
"ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning"

Recommended Fix:

Migrate to modern wait strategy pattern:

from testcontainers.core.wait_strategies import ExecWaitStrategy

class ValkeyContainer(DockerContainer):
    def __init__(self, image: str = "valkey/valkey:latest", port: int = 6379, **kwargs) -> None:
        super().__init__(image, **kwargs)
        self.port = port
        self.password: Optional[str] = None
        self.with_exposed_ports(self.port)
        
    def start(self) -> "ValkeyContainer":
        # Build wait strategy based on password
        if self.password:
            # Use custom wait strategy for authenticated connections
            self.waiting_for(self._create_auth_wait_strategy())
        else:
            # Use exec strategy for simple PING
            self.waiting_for(
                ExecWaitStrategy(["valkey-cli", "ping"])
            )
        super().start()
        return self

Benefits:

  • Aligns with project direction
  • More composable and testable
  • Consistent with other modules
  • Better error messages

Copy link
Owner Author

Choose a reason for hiding this comment

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

fixed

def _connect(self) -> None:
"""Wait for Valkey to be ready by sending PING command."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((self.get_host(), self.get_exposed_port()))
if self.password:
s.sendall(f"*2\r\n$4\r\nAUTH\r\n${len(self.password)}\r\n{self.password}\r\n".encode())
auth_response = s.recv(1024)
if b"+OK" not in auth_response:
raise ValkeyNotReady("Authentication failed")
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
if b"+PONG" not in response:
raise ValkeyNotReady("Valkey not ready yet")

def start(self) -> "ValkeyContainer":
"""
Start the container and wait for it to be ready.

Returns:
self: Started container instance.
"""
super().start()
self._connect()
return self
79 changes: 79 additions & 0 deletions modules/valkey/tests/test_valkey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import socket

from testcontainers.valkey import ValkeyContainer


def test_docker_run_valkey():
with ValkeyContainer() as valkey:
host = valkey.get_host()
port = valkey.get_exposed_port()

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
assert b"+PONG" in response


def test_docker_run_valkey_with_password():
with ValkeyContainer().with_password("mypass") as valkey:
host = valkey.get_host()
port = valkey.get_exposed_port()

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
# Authenticate
s.sendall(b"*2\r\n$4\r\nAUTH\r\n$6\r\nmypass\r\n")
auth_response = s.recv(1024)
assert b"+OK" in auth_response

# Test SET command
s.sendall(b"*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n")
set_response = s.recv(1024)
assert b"+OK" in set_response

# Test GET command
s.sendall(b"*2\r\n$3\r\nGET\r\n$5\r\nhello\r\n")
get_response = s.recv(1024)
assert b"world" in get_response


def test_get_connection_url():
with ValkeyContainer() as valkey:
url = valkey.get_connection_url()
assert url.startswith("valkey://")
assert str(valkey.get_exposed_port()) in url


def test_get_connection_url_with_password():
with ValkeyContainer().with_password("secret") as valkey:
url = valkey.get_connection_url()
assert url.startswith("valkey://:secret@")
assert str(valkey.get_exposed_port()) in url


def test_with_image_tag():
container = ValkeyContainer().with_image_tag("8.0")
assert "valkey/valkey:8.0" in container.image


def test_with_bundle():
container = ValkeyContainer().with_bundle()
assert container.image == "valkey/valkey-bundle:latest"


def test_with_bundle_and_tag():
container = ValkeyContainer().with_bundle().with_image_tag("9.0")
assert container.image == "valkey/valkey-bundle:9.0"


def test_bundle_starts():
with ValkeyContainer().with_bundle() as valkey:
host = valkey.get_host()
port = valkey.get_exposed_port()

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.sendall(b"*1\r\n$4\r\nPING\r\n")
response = s.recv(1024)
assert b"+PONG" in response
3 changes: 2 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ packages = [
{ include = "testcontainers", from = "modules/selenium" },
{ include = "testcontainers", from = "modules/scylla" },
{ include = "testcontainers", from = "modules/trino" },
{ include = "testcontainers", from = "modules/valkey" },
{ include = "testcontainers", from = "modules/vault" },
{ include = "testcontainers", from = "modules/weaviate" },
]
Expand Down Expand Up @@ -188,6 +189,7 @@ rabbitmq = ["pika"]
redis = ["redis"]
registry = ["bcrypt"]
selenium = ["selenium"]
valkey = []
scylla = ["cassandra-driver"]
sftp = ["cryptography"]
vault = []
Expand Down