Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
94a84dd
Add interoperability tests between Python and TypeScript using Redis
bigboateng Apr 1, 2025
ee69d3d
Update interop tests to work with proper Python and TypeScript ES mod…
bigboateng Apr 1, 2025
0411d08
Update run_tests.sh scripts to activate virtual environments
bigboateng Apr 1, 2025
5b79daf
Add tsconfig.json and update TypeScript command to use npx ts-node
bigboateng Apr 1, 2025
9f8c36e
Update TypeScript files to use .mjs extension
bigboateng Apr 1, 2025
dfff7c2
Update imports to use dist folder for TypeScript
bigboateng Apr 1, 2025
1157056
Fix TypeScript imports to use index.js
bigboateng Apr 1, 2025
8c11a2c
Add ioredis dependency installation to run_tests.sh scripts
bigboateng Apr 1, 2025
96976ec
Fix localStorage detection in interop tests by using absolute paths a…
bigboateng Apr 1, 2025
d55978a
Add explicit assertions to fail immediately if any verification step …
bigboateng Apr 1, 2025
e77f42d
Fix: Updated RedisStorage to use ZIP format to match TypeScript imple…
bigboateng Apr 1, 2025
843586a
Chore: Added .gitignore for virtual environments and Redis dump files
bigboateng Apr 1, 2025
9dff345
Chore: Added ioredis dependency to TypeScript-Redis-Python tests
bigboateng Apr 1, 2025
446d9db
Chore: Removed debug and test import files that are not needed
bigboateng Apr 1, 2025
1305b88
Fix: Improved Redis key format validation for interoperability
bigboateng Apr 1, 2025
3999a45
Added chrome-redis-safari test, chrome and safari localStorage is dif…
sagar448 Apr 1, 2025
cbbb137
Added the python version for chrome and safari
sagar448 Apr 1, 2025
681737f
COmbination of interop tests
sagar448 Apr 2, 2025
6543287
Finished adding all tests
sagar448 Apr 2, 2025
127bd83
Finished matrix interop testing, runs through combination of cross-la…
sagar448 Apr 2, 2025
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
52 changes: 51 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,54 @@ typescript/examples/google/service-account.json
typescript/examples/s3/aws-credentials.json

# S3 Configuration
typescript/examples/s3/config.json
typescript/examples/s3/config.json

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
dist/
build/
*.egg-info/

# Virtual environments
venv/
env/
ENV/
.venv/
.env/

# Node.js dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log

# IDE specific files
.idea/
.vscode/
*.swp
*.swo

# Testing
.coverage
coverage/
htmlcov/

# Redis
dump.rdb
*.rdb

# OS specific
.DS_Store
Thumbs.db

# Local configuration
.env
.env.local
.env.*.local
177 changes: 128 additions & 49 deletions python/browserstate/storage/redis_storage.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import os
import io
import tarfile
import zipfile
import tempfile
import shutil
import logging
import base64
import json
import time
from typing import List

import redis # Requires: pip install redis
Expand All @@ -13,7 +16,7 @@
class RedisStorage(StorageProvider):
"""
Storage provider implementation that uses Redis to store browser sessions
as compressed tar archives.
as compressed ZIP archives to match the TypeScript implementation.
"""

def __init__(self,
Expand All @@ -24,50 +27,33 @@ def __init__(self,

Args:
redis_url: Redis connection URL.
key_prefix: Prefix to use for keys in Redis.
key_prefix: Prefix to use for keys in Redis. Must not contain colons.
"""
# Validate key_prefix format
if ":" in key_prefix:
raise ValueError("key_prefix must not contain colons (:). The implementation automatically builds Redis keys in the format: {prefix}{userId}:{sessionId}")

self.redis_client = redis.Redis.from_url(redis_url)
self.key_prefix = key_prefix
logging.info(f"Redis storage initialized with prefix: {self.key_prefix}")

def _get_key(self, user_id: str, session_id: str) -> str:
"""
Generate a Redis key for a given user and session.
"""
return f"{self.key_prefix}:{user_id}:{session_id}"
"""Generate a Redis key for a given user and session."""
return f"{self.key_prefix}{user_id}:{session_id}"

def _get_metadata_key(self, user_id: str, session_id: str) -> str:
"""Generate a Redis key for session metadata."""
return f"{self.key_prefix}{user_id}:{session_id}:metadata"

def _get_temp_path(self, user_id: str, session_id: str) -> str:
"""
Get a temporary path for a session similar to S3Storage implementation.

Args:
user_id: User identifier.
session_id: Session identifier.

Returns:
Full path to the temporary session directory.
"""
"""Get a temporary path for a session."""
temp_dir = os.path.join(tempfile.gettempdir(), "browserstate", user_id)
os.makedirs(temp_dir, exist_ok=True)
return os.path.join(temp_dir, session_id)

def _safe_extract(self, tar_obj: tarfile.TarFile, path: str) -> None:
"""
Safely extract tar file to prevent path traversal vulnerabilities.
"""
def is_within_directory(directory: str, target: str) -> bool:
abs_directory = os.path.abspath(directory)
abs_target = os.path.abspath(target)
return os.path.commonprefix([abs_directory, abs_target]) == abs_directory

for member in tar_obj.getmembers():
member_path = os.path.join(path, member.name)
if not is_within_directory(path, member_path):
raise Exception("Attempted Path Traversal in Tar File")
tar_obj.extractall(path)

def download(self, user_id: str, session_id: str) -> str:
"""
Downloads a browser session from Redis, decompresses it, and writes it
Downloads a browser session from Redis, extracts the ZIP archive, and writes it
to a local temporary directory.

Args:
Expand All @@ -78,22 +64,51 @@ def download(self, user_id: str, session_id: str) -> str:
Path to the local directory containing the session data.
"""
key = self._get_key(user_id, session_id)
tar_bytes = self.redis_client.get(key)
metadata_key = self._get_metadata_key(user_id, session_id)

logging.info(f"Looking up session data at Redis key: {key}")

# Get base64-encoded zip data from Redis
zip_data_base64 = self.redis_client.get(key)

target_path = self._get_temp_path(user_id, session_id)

if os.path.exists(target_path):
shutil.rmtree(target_path)
os.makedirs(target_path, exist_ok=True)

if tar_bytes is None:
if zip_data_base64 is None:
# No session found; return an empty directory.
logging.info(f"No session found at key: {key}")
return target_path

try:
tar_stream = io.BytesIO(tar_bytes)
with tarfile.open(fileobj=tar_stream, mode="r:gz") as tar:
self._safe_extract(tar, target_path)
# Decode base64 data
logging.info(f"Found session data of size: {len(zip_data_base64)} bytes")
zip_data = base64.b64decode(zip_data_base64)
logging.info(f"Decoded base64 data of size: {len(zip_data)} bytes")

# Create temporary zip file
zip_file_path = os.path.join(
tempfile.gettempdir(),
f"{user_id}-{session_id}-{os.getpid()}.zip"
)

# Write zip data to temporary file
with open(zip_file_path, 'wb') as f:
f.write(zip_data)

logging.info(f"Extracting ZIP file to: {target_path}")

# Extract zip file to target directory
with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
zip_ref.extractall(target_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential Zip Slip vulnerability: The ZIP extraction does not verify file paths. Consider validating entries before extraction to prevent path traversal.


# Clean up temporary zip file
os.remove(zip_file_path)

logging.info(f"Extracted session data to {target_path}")

except Exception as e:
logging.error(f"Error extracting session from Redis: {e}")
raise
Expand All @@ -102,22 +117,73 @@ def download(self, user_id: str, session_id: str) -> str:

def upload(self, user_id: str, session_id: str, file_path: str) -> None:
"""
Compresses the session directory into a tar.gz archive and uploads it to Redis.
Compresses the session directory into a ZIP archive and uploads it to Redis.
Uses base64 encoding to match TypeScript implementation.

Args:
user_id: User identifier.
session_id: Session identifier.
file_path: Path to the local directory containing session data.
"""
key = self._get_key(user_id, session_id)
tar_stream = io.BytesIO()
metadata_key = self._get_metadata_key(user_id, session_id)

logging.info(f"Uploading session to Redis key: {key}")

# Create temporary zip file
zip_file_path = os.path.join(
tempfile.gettempdir(),
f"{user_id}-{session_id}-{os.getpid()}.zip"
)

try:
with tarfile.open(fileobj=tar_stream, mode="w:gz") as tar:
tar.add(file_path, arcname=os.path.basename(file_path))
tar_bytes = tar_stream.getvalue()
self.redis_client.set(key, tar_bytes)
# Create ZIP archive with maximum compression
with zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as zipf:
for root, dirs, files in os.walk(file_path):
for file in files:
file_path_full = os.path.join(root, file)
try:
arcname = os.path.relpath(file_path_full, file_path)
zipf.write(file_path_full, arcname)
except Exception as e:
logging.warning(f"Error adding file to ZIP: {file_path_full} - {e}")

# Read zip file as binary
with open(zip_file_path, 'rb') as f:
zip_bytes = f.read()

# Get file size for logging
zip_size = os.path.getsize(zip_file_path)
logging.info(f"Created ZIP archive of size: {zip_size} bytes")

# Convert to base64 for Redis storage (matching TypeScript implementation)
zip_base64 = base64.b64encode(zip_bytes)
logging.info(f"Base64 encoded data size: {len(zip_base64)} bytes")

# Store in Redis
self.redis_client.set(key, zip_base64)

# Create metadata (matching TypeScript metadata format)
metadata = {
"timestamp": time.time() * 1000, # Current time in milliseconds
"version": "2.0",
}

# Store metadata in Redis
self.redis_client.set(metadata_key, json.dumps(metadata))

# Clean up temporary zip file
os.remove(zip_file_path)

logging.info(f"Successfully uploaded session {session_id} to Redis at key: {key}")

except Exception as e:
logging.error(f"Error uploading session to Redis: {e}")

# Clean up temporary zip file if it exists
if os.path.exists(zip_file_path):
os.remove(zip_file_path)

raise

def list_sessions(self, user_id: str) -> List[str]:
Expand All @@ -130,15 +196,23 @@ def list_sessions(self, user_id: str) -> List[str]:
Returns:
List of session identifiers.
"""
pattern = f"{self.key_prefix}:{user_id}:*"
pattern = f"{self.key_prefix}{user_id}:*"
logging.info(f"Listing sessions with pattern: {pattern}")

try:
keys = self.redis_client.keys(pattern)
session_ids = []
for key in keys:
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
parts = key_str.split(':')
if len(parts) == 3:
session_ids.append(parts[2])
# Extract sessionId from key
parts = key_str[len(self.key_prefix) + len(user_id) + 1:].split(':')
session_id = parts[0]

# Exclude metadata keys and deduplicate
if len(parts) == 1 and session_id not in session_ids:
session_ids.append(session_id)

logging.info(f"Found {len(session_ids)} sessions for user {user_id}")
return session_ids
except Exception as e:
logging.error(f"Error listing sessions in Redis: {e}")
Expand All @@ -153,8 +227,13 @@ def delete_session(self, user_id: str, session_id: str) -> None:
session_id: Session identifier.
"""
key = self._get_key(user_id, session_id)
metadata_key = self._get_metadata_key(user_id, session_id)
logging.info(f"Deleting session at keys: {key}, {metadata_key}")

try:
self.redis_client.delete(key)
# Delete both session data and metadata
self.redis_client.delete(key, metadata_key)
logging.info(f"Successfully deleted session {session_id}")
except Exception as e:
logging.error(f"Error deleting session from Redis: {e}")
raise
21 changes: 21 additions & 0 deletions tests/interop/python_tests/run_python_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import argparse
import asyncio
from test_browser_state import create_state, verify_state

def parse_args():
parser = argparse.ArgumentParser(description="Python BrowserState Test Runner")
parser.add_argument("--mode", choices=["create", "verify"], required=True, help="Test mode: create or verify state")
parser.add_argument("--browser", choices=["chromium", "webkit", "firefox"], required=True, help="Browser to use")
parser.add_argument("--session", required=True, help="Session ID to use for test")
return parser.parse_args()

async def main():
args = parse_args()
if args.mode == "create":
await create_state(args.browser, args.session)
elif args.mode == "verify":
await verify_state(args.browser, args.session)

if __name__ == "__main__":
asyncio.run(main())
Loading