Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
29 changes: 27 additions & 2 deletions percy/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,33 @@
def _get_bool_env(key):
return os.environ.get(key, "").lower() == "true"

# Maybe get the CLI API address from the environment
PERCY_CLI_API = os.environ.get('PERCY_CLI_API') or 'http://localhost:5338'
DEFAULT_PERCY_CLI_API = 'http://localhost:5338'
_LOOPBACK_HOSTS = frozenset({'localhost', '127.0.0.1', '::1'})


def _resolve_cli_api_address():
# The Percy CLI runs locally. PERCY_CLI_API is used as the base for every
# outbound call AND as the source of the @percy/dom script that is injected
# and executed in the browser, and the healthcheck response drives the
# session-type auth gate. An attacker-controlled value therefore enables
# SSRF, CLI-fetched-JS RCE, and an auth-gate bypass (CWE-918/CWE-94/CWE-306).
# Restrict it to loopback; allow a remote host only over HTTPS with an
# explicit opt-in, otherwise warn and fall back to the local default.
raw = os.environ.get('PERCY_CLI_API') or DEFAULT_PERCY_CLI_API
host = (urlparse(raw).hostname or '').lower()
if host in _LOOPBACK_HOSTS:
return raw
allow_remote = os.environ.get('PERCY_ALLOW_REMOTE_CLI_API', '').lower() in ('1', 'true', 'yes')
if allow_remote and urlparse(raw).scheme == 'https':
return raw
print(f"[percy] Ignoring non-loopback PERCY_CLI_API '{raw}'; falling back to "
f"{DEFAULT_PERCY_CLI_API}. To target a remote Percy CLI use an https:// URL "
"and set PERCY_ALLOW_REMOTE_CLI_API=true.")
return DEFAULT_PERCY_CLI_API


# Maybe get the CLI API address from the environment (validated to loopback)
PERCY_CLI_API = _resolve_cli_api_address()
PERCY_DEBUG = os.environ.get('PERCY_LOGLEVEL') == 'debug'
RESPONSIVE_CAPTURE_SLEEP_TIME = (
os.environ.get('RESPONSIVE_CAPTURE_SLEEP_TIME') or
Expand Down
35 changes: 35 additions & 0 deletions tests/test_snapshot.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=too-many-lines
import os
import unittest
from unittest.mock import patch, Mock
from http.server import BaseHTTPRequestHandler, HTTPServer
Expand All @@ -18,6 +19,8 @@
_resolve_readiness_config,
_wait_for_ready,
get_serialized_dom,
_resolve_cli_api_address,
DEFAULT_PERCY_CLI_API,
)

from percy import percy_snapshot, percySnapshot, percy_screenshot
Expand Down Expand Up @@ -124,6 +127,38 @@ def mock_screenshot(fail=False, data=False):
}),
status=(500 if fail else 200))

class TestResolveCliApiAddress(unittest.TestCase):
@patch.dict(os.environ, {}, clear=True)
def test_defaults_to_localhost(self):
self.assertEqual(_resolve_cli_api_address(), DEFAULT_PERCY_CLI_API)

@patch.dict(os.environ, {'PERCY_CLI_API': 'http://127.0.0.1:5338'}, clear=True)
def test_allows_loopback(self):
self.assertEqual(_resolve_cli_api_address(), 'http://127.0.0.1:5338')

@patch.dict(os.environ, {'PERCY_CLI_API': 'http://attacker.example/x'}, clear=True)
def test_rejects_remote_host(self):
self.assertEqual(_resolve_cli_api_address(), DEFAULT_PERCY_CLI_API)

@patch.dict(os.environ, {'PERCY_CLI_API': 'http://169.254.169.254/latest/meta-data'}, clear=True)
def test_rejects_link_local_ssrf_target(self):
self.assertEqual(_resolve_cli_api_address(), DEFAULT_PERCY_CLI_API)

@patch.dict(os.environ, {
'PERCY_CLI_API': 'https://percy-cli.internal:5338',
'PERCY_ALLOW_REMOTE_CLI_API': 'true'
}, clear=True)
def test_allows_remote_https_with_opt_in(self):
self.assertEqual(_resolve_cli_api_address(), 'https://percy-cli.internal:5338')

@patch.dict(os.environ, {
'PERCY_CLI_API': 'http://percy-cli.internal:5338',
'PERCY_ALLOW_REMOTE_CLI_API': 'true'
}, clear=True)
def test_remote_opt_in_still_requires_https(self):
self.assertEqual(_resolve_cli_api_address(), DEFAULT_PERCY_CLI_API)


# pylint: disable=too-many-public-methods
class TestPercySnapshot(unittest.TestCase):
@classmethod
Expand Down
Loading