mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
f4fd7e1f67
* fix(security): classify outbound addresses by what they reach (GHSA-wm4f-79pw-pfr9)
Five guards screened outbound URLs and each hand-rolled its own address
normalization and policy tests, so each had a different hole. An IPv6
transition address carries an IPv4 destination in its low bits and
`ipaddress` classifies the wrapper, not the destination: 64:ff9b::a9fe:a9fe
reports is_global because 64:ff9b::/96 is global unicast, while a NAT64
gateway routes it to the cloud metadata endpoint. CGNAT (100.64.0.0/10) is
neither is_private nor is_global, so a denylist built on is_private missed
it with no gateway involved at all.
Add turnstone/core/ip_classify.py as the single classifier. One function
returns exactly one policy lane — PUBLIC, PRIVATE (operator-approvable) or
NEVER — and every guard branches on the lane rather than re-deriving it.
Two overlapping booleans would make a verdict depend on which one a caller
tested first; several addresses are simultaneously globally routable and
metadata-reaching.
- Decode transition addresses per RFC 6052 §2.2 (NAT64 well-known and
local-use prefixes, 6to4, Teredo, IPv4-mapped, IPv4-compatible) and judge
them by the IPv4 they reach. The local-use prefix does not say which
layout its gateway uses, so every length it can carry is decoded and the
worst result classified.
- Share hostname resolution too. The five copies had already drifted on
which failures they caught, and getaddrinfo raises UnicodeError — not an
OSError — from the IDNA encoder.
- Resolution failure is a refusal, not a pass: the fetch resolves again, so
an authority answering the guard with SERVFAIL and the fetch with an
internal address would otherwise switch the guard off for that hop.
- Screen every redirect hop in every mode. allow_private_origin widens which
lanes are acceptable rather than turning screening off, and the permission
is revoked after any hop that is not wholly private.
- Cleartext http is allowed only for a hostname that RESOLVES to loopback.
*.localhost is ordinary DNS, and trusting the name put an OIDC token
exchange on the wire in the clear.
- Screen doctor and console-probe URLs through the classifier. Both used a
host.startswith("169.254.") string test that never resolved, so any DNS
name pointing at the metadata service passed and its body was returned to
the model.
- Add known vendor metadata prefixes the stdlib does not flag, and place
deprecated IPv6 site-local outside the public lane.
The operator's private-network opt-in still admits the whole home lab,
including IPv6 loopback, CGNAT and split-horizon hosts. Metadata,
link-local, multicast, unspecified and reserved addresses stay refused
regardless of the opt-in, including as a redirect target from an approved
private origin — the settings help and docs now say so.
Reported by @tonghuaroot.
* fix(security): close Azure/Oracle metadata gap and restore dual-stack origins
Review follow-ups on the address-classification rework.
Azure's host-agent endpoint (168.63.129.16) and Oracle Cloud's metadata
endpoint (192.0.0.192) sit in ordinary unicast space, so the stdlib reported
them as globally routable and both classified PUBLIC — reachable with no
opt-in at all, a worse position than the RFC 1918 host beside them, and
directly contradicting the "metadata stays refused even with the opt-in"
guarantee the settings help and docs now advertise. Both join the shared
vendor list.
Revoking the private-hop permission on the ORIGIN hop broke the case
`_screen_tool_url` deliberately admits: a dual-stack or split-horizon
home-lab host answering with both a LAN and a public record was approved,
then refused on its own `302 /login` — one hop was all it ever got. Track
the approved HOST instead, so redirects that stay on it remain covered while
a redirect to any other private host is still refused once the chain is no
longer wholly private.
Also:
- Try several registry candidates for the collector-scope probe instead of
abandoning it when the first is unresolvable, which also stopped a healthy
registry from logging as malformed.
- Bound the probe's name resolution with an explicit timeout matching the
2s the httpx connect deadline used to provide; it runs before the console
lifespan yields and getaddrinfo has no timeout of its own.
- Route doctor and the console probe through `web.screen_url` rather than
keeping a third and fourth copy of parse/resolve/classify/fold, which had
already diverged on default port and empty-hostname wording. An empty
hostname no longer reports as a cloud-metadata refusal.
- Give `screen_url` a scheme-aware default port.
- Stop doubling the word "hostname" in the OAuth resolution refusal.
- Correct the `_screen_tool_url` docstring: it described `private_origin` as
requiring every record to be private, which the mixed-record decision
reversed, and `private_block` as a property of a refusal when it reports
the lane on the success path too.
- Make the preview tests' screening stub opt-in rather than autouse — as a
module-wide fixture it also stubbed the tests whose subject IS the screen,
so one of them would have passed even if screening refused everything.
Verified the module now passes with all name resolution blocked.
* fix(security): refuse mixed-record private origins instead of exempting them
The previous commit let an approved private origin redirect to itself by
exempting its hostname from the chain-wide revocation. That exemption was
wrong three ways: it was captured once and never cleared, so a public hop
could steer the fetcher back into the approved host at a path of its
choosing — reopening the private -> public -> private bypass; it was
re-entrant across same-host redirects with fresh DNS each time, so a
self-redirecting host could walk arbitrary internal addresses; and it
matched on bare hostname, so it spanned every port on the approved box.
All three were reproduced against the parent commit, which refuses them.
Delete the exemption rather than repair it. The case it existed for — a
dual-stack host answering with both a LAN and a public record — is now
refused where it is actually decidable, in `_screen_tool_url`, with the
remedy in the message: point the tool at the LAN address directly. A
granted chain therefore always starts wholly private, so the fetch guard
needs no notion of an approved host and stays one unconditional rule.
That the accommodation could not be expressed safely in the guard is the
signal: the connection may land on either record, so approving such a host
never described where the fetch would go.
Also from the same review:
- Walk the whole service registry for a collector-scope probe candidate
instead of the first three, and split the outcome into three log lines,
so entries that are merely unreachable stop raising the malformed-registry
alarm and skipping the boot check cluster-wide.
- Stop the candidate walk on a resolver timeout. `asyncio.timeout` bounds
the await, not the work, so continuing left one parked thread per timed-out
candidate on the shared executor.
- Move the metadata-hostname denylist into `ip_classify` and enforce it in
`screen_url`, so doctor and the console probe inherit it instead of each
keeping a copy.
- Drop the scheme-aware default port: a numeric service does not change
which addresses resolution returns, and classification reads only those.
`parsed.port` is still touched so an out-of-range value refuses.
- Correct the vendor-metadata comment, which generalized a claim true of
Azure's and Oracle's addresses to Alibaba's CGNAT one.
- Rename a test class that was still named for the rule it no longer tests.
1044 lines
41 KiB
Python
1044 lines
41 KiB
Python
"""End-to-end coverage for the ``open_preview`` tool wiring.
|
|
|
|
Spans the seams the preview descriptor rides: preparer validation +
|
|
approval posture, executor target resolution (mocked ``httpx`` for URLs,
|
|
tmp files for paths, monkeypatched storage for attachments), the
|
|
``_tool_previews`` side channel + live SSE event, the ``Turn.meta``
|
|
round-trip, the ``/history`` projection, the storage reconstruct routing,
|
|
and the auth scope of the serving route.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.session import ChatSession
|
|
from turnstone.core.trajectory import Role, turn_from_dict, turn_to_dict
|
|
|
|
PNG_1x1 = (
|
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
|
|
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
)
|
|
|
|
|
|
class _RecordingUI:
|
|
"""SessionUI double that records tool_result calls (kwargs included)."""
|
|
|
|
def __init__(self):
|
|
self.tool_results = []
|
|
|
|
def __getattr__(self, name):
|
|
# Every other SessionUI hook is an inert no-op.
|
|
def _noop(*args, **kwargs):
|
|
return None
|
|
|
|
return _noop
|
|
|
|
def on_tool_result(self, call_id, name, output, **kwargs):
|
|
self.tool_results.append((call_id, name, output, kwargs))
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_network_screen(monkeypatch):
|
|
"""Keep prepare-time SSRF screening off the network.
|
|
|
|
Opt-in, NOT autouse: as a module-wide fixture it also stubbed the tests
|
|
whose whole point is the screen, so ``test_screen_public_url_passes``
|
|
asserted on the stub and would have passed even if screen_url refused
|
|
every hostname. Request it only where the hostname is incidental.
|
|
|
|
Screening fails closed on a resolution failure, so a test naming a
|
|
third-party host (``example.com``) would otherwise depend on live public
|
|
DNS and break on an isolated CI runner. IP literals are passed through to
|
|
the real screen — they resolve locally, and the tests that exercise the
|
|
private/never lanes are written with literals precisely so they exercise
|
|
the real classifier.
|
|
"""
|
|
import ipaddress
|
|
from urllib.parse import urlparse
|
|
|
|
from turnstone.core import web
|
|
|
|
real = web.screen_url
|
|
permissive = _screen_stub()
|
|
|
|
def _screen(url):
|
|
try:
|
|
host = urlparse(url).hostname or ""
|
|
except ValueError:
|
|
return real(url)
|
|
if not host:
|
|
return real(url) # malformed URLs are the real screen's business
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return permissive(url)
|
|
return real(url)
|
|
|
|
monkeypatch.setattr("turnstone.core.session.screen_url", _screen)
|
|
|
|
|
|
def _make_session(**kwargs):
|
|
defaults = dict(
|
|
client=MagicMock(),
|
|
model="test-model",
|
|
ui=_RecordingUI(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=5,
|
|
)
|
|
defaults.update(kwargs)
|
|
return ChatSession(**defaults)
|
|
|
|
|
|
def _fake_response(url, body, content_type):
|
|
import httpx
|
|
|
|
resp = SimpleNamespace()
|
|
# A real httpx.URL so the executor's userinfo-strip path runs unmocked.
|
|
resp.url = httpx.URL(url)
|
|
resp.content = body
|
|
resp.text = body.decode("utf-8", errors="replace")
|
|
resp.headers = {"content-type": content_type}
|
|
resp.raise_for_status = lambda: None
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preparer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrepareOpenPreview:
|
|
def test_missing_target_errors(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {})
|
|
assert item["error"].startswith("Error: missing target")
|
|
|
|
def test_invalid_kind_errors(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "a.txt", "kind": "hologram"})
|
|
assert "kind must be one of" in item["error"]
|
|
|
|
def test_url_target_needs_approval(self, _no_network_screen):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "https://example.com/x"})
|
|
assert item["needs_approval"] is True
|
|
assert item["target_kind"] == "url"
|
|
assert item["approval_label"] == "open_preview"
|
|
assert "error" not in item
|
|
|
|
def test_private_url_blocked_pre_approval(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "http://169.254.169.254/meta"})
|
|
assert "error" in item
|
|
assert item["needs_approval"] is False
|
|
|
|
def test_path_target_runs_unprompted(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "~/notes.md"})
|
|
assert item["needs_approval"] is False
|
|
assert item["target_kind"] == "path"
|
|
assert not item["path"].startswith("~")
|
|
|
|
def test_attachment_target(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "attachment:abc123"})
|
|
assert item["needs_approval"] is False
|
|
assert item["target_kind"] == "attachment"
|
|
assert item["attachment_id"] == "abc123"
|
|
empty = s._prepare_open_preview("c1", {"target": "attachment:"})
|
|
assert "error" in empty
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Executor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExecOpenPreview:
|
|
def test_url_html_builds_web_descriptor(self, _no_network_screen, monkeypatch):
|
|
s = _make_session()
|
|
body = b"<html><head><title>Acme Pricing</title></head><body>x</body></html>"
|
|
monkeypatch.setattr(
|
|
"turnstone.core.session.fetch_with_ssrf_guard",
|
|
lambda url, **kw: _fake_response(url, body, "text/html; charset=utf-8"),
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "https://acme.com/pricing"})
|
|
call_id, msg = s._exec_open_preview(item)
|
|
assert call_id == "c1"
|
|
assert "Acme Pricing" in msg
|
|
descriptor, att = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "web"
|
|
assert descriptor["title"] == "Acme Pricing"
|
|
assert descriptor["source"] == "https://acme.com/pricing"
|
|
assert descriptor["content_type"].startswith("text/html")
|
|
assert att.kind == "preview"
|
|
# The stored bytes gained a base for relative-asset resolution.
|
|
assert b'<base href="https://acme.com/pricing">' in att.content
|
|
# The live event carried the descriptor.
|
|
results = s.ui.tool_results
|
|
assert results and results[-1][3].get("preview") == descriptor
|
|
|
|
def test_url_userinfo_stripped_from_descriptor(self, _no_network_screen, monkeypatch):
|
|
s = _make_session()
|
|
body = b"<html><head></head><body>x</body></html>"
|
|
monkeypatch.setattr(
|
|
"turnstone.core.session.fetch_with_ssrf_guard",
|
|
lambda url, **kw: _fake_response(url, body, "text/html"),
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "https://user:sekret@acme.com/page"})
|
|
s._exec_open_preview(item)
|
|
descriptor, att = s._tool_previews["c1"]
|
|
assert "sekret" not in descriptor["source"]
|
|
assert "sekret" not in descriptor["title"]
|
|
assert b"sekret" not in att.content # the injected <base href>
|
|
|
|
def test_redirect_into_private_space_blocked(self, _no_network_screen, monkeypatch):
|
|
s = _make_session()
|
|
|
|
# The guarded fetch raises BEFORE requesting a private hop — the
|
|
# executor's ValueError lane turns that into a tool error.
|
|
def _blocked(url, **kw):
|
|
raise ValueError("Blocked: URL resolves to private/internal address (169.254.169.254)")
|
|
|
|
monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _blocked)
|
|
# The prepare-time screen fails closed on an unresolvable host, and
|
|
# innocent.example does not resolve — stub it so this test exercises
|
|
# the executor's ValueError lane rather than the screen.
|
|
monkeypatch.setattr("turnstone.core.session.screen_url", _screen_stub())
|
|
item = s._prepare_open_preview("c1", {"target": "https://innocent.example/"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert msg.startswith("Error: fetch failed: Blocked")
|
|
assert "c1" not in s._tool_previews
|
|
|
|
def test_oversized_web_content_errors(self, _no_network_screen, monkeypatch):
|
|
s = _make_session()
|
|
big = b"<html>" + b"x" * (4 * 1024 * 1024 + 16) + b"</html>"
|
|
monkeypatch.setattr(
|
|
"turnstone.core.session.fetch_with_ssrf_guard",
|
|
lambda url, **kw: _fake_response(url, big, "text/html"),
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "https://example.com/big"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert msg.startswith("Error:")
|
|
assert "too large" in msg
|
|
|
|
def test_url_pdf_over_10mb_previews_to_kind_cap(self, _no_network_screen, monkeypatch):
|
|
# Review finding (PR #800): a flat 10 MB URL pre-check rejected PDFs
|
|
# the 32 MiB pdf kind cap allows — the fetch ceiling must track the
|
|
# widest kind cap and leave the per-kind caps as the authority.
|
|
from turnstone.core.preview import PREVIEW_SIZE_CAPS
|
|
|
|
s = _make_session()
|
|
body = b"%PDF-1.7\n" + b"a" * (12 * 1024 * 1024)
|
|
seen = {}
|
|
|
|
def _capture(url, **kw):
|
|
seen.update(kw)
|
|
return _fake_response(url, body, "application/pdf")
|
|
|
|
monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _capture)
|
|
item = s._prepare_open_preview("c1", {"target": "https://acme.com/report.pdf"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert not msg.startswith("Error:")
|
|
descriptor, _ = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "pdf"
|
|
assert descriptor["size"] == len(body)
|
|
assert seen["max_bytes"] == max(PREVIEW_SIZE_CAPS.values())
|
|
|
|
def test_path_image(self, tmp_path):
|
|
s = _make_session()
|
|
p = tmp_path / "chart.png"
|
|
p.write_bytes(PNG_1x1)
|
|
item = s._prepare_open_preview("c1", {"target": str(p)})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert not msg.startswith("Error:")
|
|
descriptor, att = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "image"
|
|
assert descriptor["content_type"] == "image/png"
|
|
assert descriptor["title"] == "chart.png"
|
|
assert att.content == PNG_1x1
|
|
|
|
def test_preview_blob_id_salted_out_of_upload_namespace(self, tmp_path):
|
|
import hashlib
|
|
|
|
s = _make_session()
|
|
p = tmp_path / "chart.png"
|
|
p.write_bytes(PNG_1x1)
|
|
item = s._prepare_open_preview("c1", {"target": str(p)})
|
|
s._exec_open_preview(item)
|
|
_, att = s._tool_previews["c1"]
|
|
# Uploads are keyed bare sha256(body) and save_attachment freezes
|
|
# `kind` at first insert — an unsalted preview of identical bytes
|
|
# would collide with (or pre-empt) a real upload's row.
|
|
assert att.attachment_id != hashlib.sha256(PNG_1x1).hexdigest()
|
|
assert att.attachment_id == hashlib.sha256(b"preview:" + PNG_1x1).hexdigest()
|
|
|
|
def test_path_csv_is_table(self, tmp_path):
|
|
s = _make_session()
|
|
p = tmp_path / "results.csv"
|
|
p.write_text("name,score\na,1\nb,2\n")
|
|
item = s._prepare_open_preview("c1", {"target": str(p)})
|
|
s._exec_open_preview(item)
|
|
descriptor, _ = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "table"
|
|
assert descriptor["content_type"].startswith("text/csv")
|
|
|
|
def test_path_missing_errors(self):
|
|
s = _make_session()
|
|
item = s._prepare_open_preview("c1", {"target": "/nonexistent/nowhere.txt"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert msg.startswith("Error: file not found")
|
|
|
|
def test_path_binary_unpreviewable(self, tmp_path):
|
|
s = _make_session()
|
|
p = tmp_path / "blob.bin"
|
|
p.write_bytes(b"\x00\x01\x02\x03" * 64)
|
|
item = s._prepare_open_preview("c1", {"target": str(p)})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert "not previewable" in msg
|
|
|
|
def test_attachment_target_requires_ws_reference(self, monkeypatch):
|
|
s = _make_session(ws_id="ws-1")
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.get_attachment",
|
|
lambda aid: {"content": b"# doc", "mime_type": "text/markdown", "filename": "d.md"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.attachment_referenced_in_ws",
|
|
lambda aid, ws: False,
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "attachment:deadbeef"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert msg.startswith("Error: attachment not found")
|
|
|
|
def test_attachment_target_happy_path(self, monkeypatch):
|
|
s = _make_session(ws_id="ws-1")
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.get_attachment",
|
|
lambda aid: {"content": b"# doc", "mime_type": "text/markdown", "filename": "d.md"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.attachment_referenced_in_ws",
|
|
lambda aid, ws: True,
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "attachment:deadbeef"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert not msg.startswith("Error:")
|
|
descriptor, _ = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "markdown"
|
|
assert descriptor["title"] == "d.md"
|
|
|
|
def test_legacy_charset_table_stored_as_utf8(self, monkeypatch):
|
|
# A latin-1 CSV attachment previews as a table, and the executor
|
|
# transcodes it to UTF-8 at store time so "café" round-trips instead of
|
|
# erroring "not previewable".
|
|
s = _make_session(ws_id="ws-1")
|
|
latin1_csv = "name,city\nRené,Montréal\n".encode("iso-8859-1")
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.get_attachment",
|
|
lambda aid: {
|
|
"content": latin1_csv,
|
|
"mime_type": "text/csv; charset=iso-8859-1",
|
|
"filename": "people.csv",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"turnstone.core.memory.attachment_referenced_in_ws",
|
|
lambda aid, ws: True,
|
|
)
|
|
item = s._prepare_open_preview("c1", {"target": "attachment:deadbeef"})
|
|
_, msg = s._exec_open_preview(item)
|
|
assert not msg.startswith("Error:")
|
|
descriptor, att = s._tool_previews["c1"]
|
|
assert descriptor["kind"] == "table"
|
|
assert descriptor["content_type"].startswith("text/csv")
|
|
# Stored bytes are valid UTF-8 with the accented characters preserved.
|
|
assert att.content.decode("utf-8") == "name,city\nRené,Montréal\n"
|
|
|
|
def test_title_override_wins(self, tmp_path):
|
|
s = _make_session()
|
|
p = tmp_path / "x.csv"
|
|
p.write_text("a,b\n")
|
|
item = s._prepare_open_preview("c1", {"target": str(p), "title": "Q3 numbers"})
|
|
s._exec_open_preview(item)
|
|
descriptor, _ = s._tool_previews["c1"]
|
|
assert descriptor["title"] == "Q3 numbers"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trajectory / history / storage seams
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDescriptorSeams:
|
|
DESCRIPTOR = {
|
|
"kind": "web",
|
|
"title": "T",
|
|
"source": "https://a.io",
|
|
"attachment_id": "abc",
|
|
"content_type": "text/html; charset=utf-8",
|
|
"size": 7,
|
|
}
|
|
|
|
def test_turn_roundtrip(self):
|
|
turn = turn_from_dict(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c1",
|
|
"content": "Preview shown",
|
|
"_preview": self.DESCRIPTOR,
|
|
}
|
|
)
|
|
assert turn.meta.extra["preview"] == self.DESCRIPTOR
|
|
out = turn_to_dict(turn)
|
|
assert out["_preview"] == self.DESCRIPTOR
|
|
|
|
def test_history_projection_carries_preview(self):
|
|
from turnstone.core.history_decoration import project_history_messages
|
|
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"id": "c1", "function": {"name": "open_preview", "arguments": "{}"}}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "c1",
|
|
"content": "Preview shown to the user: T (web, 7 bytes)",
|
|
"_preview": self.DESCRIPTOR,
|
|
},
|
|
]
|
|
history = project_history_messages(msgs)
|
|
tool_entries = [h for h in history if h.get("role") == "tool"]
|
|
assert tool_entries and tool_entries[0]["preview"] == self.DESCRIPTOR
|
|
|
|
def test_reconstruct_routes_tool_preview_meta(self):
|
|
import json
|
|
|
|
from turnstone.core.storage._utils import reconstruct_turns
|
|
|
|
# Row layout per reconstruct_turns' unpack: (row_id, role, content,
|
|
# tool_name, tool_call_id, provider_data, tool_calls_json, source,
|
|
# event_id, is_error, meta).
|
|
row = (
|
|
1,
|
|
"tool",
|
|
"ok",
|
|
"open_preview",
|
|
"c1",
|
|
None,
|
|
None,
|
|
None,
|
|
7,
|
|
0,
|
|
json.dumps({"effect_status": "unknown", "preview": self.DESCRIPTOR}),
|
|
)
|
|
turns = reconstruct_turns([row], "ws-1", attachments_by_msg={})
|
|
assert turns[0].role is Role.TOOL
|
|
assert turns[0].meta.extra["preview"] == self.DESCRIPTOR
|
|
assert turns[0].meta.extra["effect_status"] == "unknown"
|
|
|
|
def test_reconstruct_skips_preview_blob_refs(self):
|
|
"""A preview blob on a tool row's ref-list must NOT become a content
|
|
block — it is meta-addressed frontend content, and a content block
|
|
would be materialized onto the wire on reload."""
|
|
from turnstone.core.storage._utils import reconstruct_turns
|
|
|
|
row = (
|
|
1,
|
|
"tool",
|
|
"ok",
|
|
"open_preview",
|
|
"c1",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
0,
|
|
None,
|
|
)
|
|
atts = {
|
|
1: [
|
|
{
|
|
"attachment_id": "abc",
|
|
"kind": "preview",
|
|
"filename": "preview-web",
|
|
"mime_type": "text/html; charset=utf-8",
|
|
"size_bytes": 7,
|
|
},
|
|
{
|
|
"attachment_id": "img1",
|
|
"kind": "image",
|
|
"filename": "shot.png",
|
|
"mime_type": "image/png",
|
|
"size_bytes": 9,
|
|
},
|
|
]
|
|
}
|
|
turns = reconstruct_turns([row], "ws-1", attachments_by_msg=atts)
|
|
kinds = [b.kind for b in turns[0].content if b.__class__.__name__ == "AttachmentRef"]
|
|
# The vision lane still reconstructs; the preview blob does not.
|
|
assert kinds == ["image"]
|
|
|
|
def test_preview_route_scope_is_read(self):
|
|
from turnstone.core.auth import required_scope
|
|
|
|
assert required_scope("GET", "/v1/api/workstreams/ws1/attachments/abc/preview") == "read"
|
|
assert (
|
|
required_scope("GET", "/node/n1/v1/api/workstreams/ws1/attachments/abc/preview")
|
|
== "read"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_with_ssrf_guard — per-hop redirect screening (core/web.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeHop:
|
|
"""client.stream() double: a context manager yielding chunked body bytes."""
|
|
|
|
def __init__(self, status, headers=None, body=b""):
|
|
self.status_code = status
|
|
self.headers = headers or {}
|
|
self._chunks = body if isinstance(body, list) else [body]
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def iter_bytes(self):
|
|
yield from self._chunks
|
|
|
|
|
|
class _FakeClient:
|
|
"""httpx.Client double: serves a scripted {url: response} table."""
|
|
|
|
calls: list[str] = []
|
|
table: dict[str, _FakeHop] = {}
|
|
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def stream(self, method, url):
|
|
_FakeClient.calls.append(url)
|
|
return _FakeClient.table[url]
|
|
|
|
|
|
def _screen_stub(blocked=None):
|
|
"""Stand in for the real per-hop screen, so tests need no DNS.
|
|
|
|
Patches what the guard actually calls. Patching a function the guard has
|
|
stopped calling would leave the test green while screening nothing.
|
|
"""
|
|
from turnstone.core.ip_classify import AddressLane
|
|
from turnstone.core.web import UrlScreen
|
|
|
|
table = blocked or {}
|
|
|
|
def _screen(url):
|
|
err = table.get(url)
|
|
if err is None:
|
|
return UrlScreen(AddressLane.PUBLIC, None, False)
|
|
return UrlScreen(AddressLane.NEVER, err, False)
|
|
|
|
return _screen
|
|
|
|
|
|
class TestFetchWithSsrfGuard:
|
|
def _wire(self, monkeypatch, table, blocked=None):
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = table
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
monkeypatch.setattr("turnstone.core.web.screen_url", _screen_stub(blocked))
|
|
|
|
def test_follows_public_redirect_chain(self, monkeypatch):
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{
|
|
"https://a.example/": _FakeHop(302, {"location": "https://b.example/x"}),
|
|
"https://b.example/x": _FakeHop(200, {}, body=b"landed"),
|
|
},
|
|
)
|
|
resp = fetch_with_ssrf_guard("https://a.example/", timeout=5)
|
|
assert resp.status_code == 200
|
|
assert _FakeClient.calls == ["https://a.example/", "https://b.example/x"]
|
|
|
|
def test_private_hop_blocked_before_request(self, monkeypatch):
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{
|
|
"https://a.example/": _FakeHop(302, {"location": "http://169.254.169.254/latest"}),
|
|
},
|
|
blocked={"http://169.254.169.254/latest": "Blocked: private"},
|
|
)
|
|
with pytest.raises(ValueError, match="Blocked: private"):
|
|
fetch_with_ssrf_guard("https://a.example/", timeout=5)
|
|
# The load-bearing assertion: the private hop was NEVER requested.
|
|
assert _FakeClient.calls == ["https://a.example/"]
|
|
|
|
def test_relative_location_resolves_against_current(self, monkeypatch):
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{
|
|
"https://a.example/start": _FakeHop(301, {"location": "/moved"}),
|
|
"https://a.example/moved": _FakeHop(200, {}),
|
|
},
|
|
)
|
|
resp = fetch_with_ssrf_guard("https://a.example/start", timeout=5)
|
|
assert resp.status_code == 200
|
|
# The realized response carries the FINAL hop's URL — open_preview's
|
|
# descriptor source and stored <base href> both key off it.
|
|
assert str(resp.url) == "https://a.example/moved"
|
|
|
|
def test_redirect_loop_capped(self, monkeypatch):
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{"https://a.example/": _FakeHop(302, {"location": "https://a.example/"})},
|
|
)
|
|
with pytest.raises(ValueError, match="redirects"):
|
|
fetch_with_ssrf_guard("https://a.example/", timeout=5)
|
|
|
|
def test_body_over_budget_aborts(self, monkeypatch):
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{"https://a.example/": _FakeHop(200, {}, body=[b"aaaa", b"bbbb", b"cccc"])},
|
|
)
|
|
with pytest.raises(ValueError, match="fetch limit"):
|
|
fetch_with_ssrf_guard("https://a.example/", timeout=5, max_bytes=10)
|
|
|
|
def test_redirect_hop_body_never_read(self, monkeypatch):
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
class _BodyBomb(_FakeHop):
|
|
def iter_bytes(self):
|
|
raise AssertionError("redirect hop body must not be read")
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{
|
|
"https://a.example/": _BodyBomb(302, {"location": "https://b.example/x"}),
|
|
"https://b.example/x": _FakeHop(200, {}, body=b"ok"),
|
|
},
|
|
)
|
|
resp = fetch_with_ssrf_guard("https://a.example/", timeout=5)
|
|
assert resp.status_code == 200
|
|
assert resp.content == b"ok"
|
|
|
|
def test_stale_framing_headers_dropped(self, monkeypatch):
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
self._wire(
|
|
monkeypatch,
|
|
{
|
|
"https://a.example/": _FakeHop(
|
|
200,
|
|
{
|
|
"content-encoding": "gzip",
|
|
"content-length": "999",
|
|
"content-type": "text/html; charset=utf-8",
|
|
},
|
|
body=b"<html>hi</html>",
|
|
)
|
|
},
|
|
)
|
|
resp = fetch_with_ssrf_guard("https://a.example/", timeout=5)
|
|
# iter_bytes() hands the guard content-DECODED bytes — a surviving
|
|
# content-encoding would make .text try to gunzip plain text, and the
|
|
# upstream content-length no longer describes the body carried.
|
|
assert "content-encoding" not in resp.headers
|
|
assert resp.headers.get("content-length") != "999"
|
|
assert resp.headers.get("content-type") == "text/html; charset=utf-8"
|
|
assert resp.text == "<html>hi</html>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cancelled-batch synthesis — a staged preview whose descriptor already
|
|
# reached the frontend must commit, not vanish (session.py review fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCancelledBatchPreservesPreview:
|
|
def test_synthesize_commits_staged_preview(self, monkeypatch):
|
|
import json as _json
|
|
|
|
from turnstone.core.attachments import Attachment
|
|
from turnstone.core.trajectory import Turn
|
|
|
|
s = _make_session(ws_id="ws-1")
|
|
descriptor = {
|
|
"kind": "web",
|
|
"title": "T",
|
|
"source": "https://a.io",
|
|
"attachment_id": "abc",
|
|
"content_type": "text/html; charset=utf-8",
|
|
"size": 7,
|
|
}
|
|
att = Attachment(
|
|
attachment_id="abc",
|
|
filename="preview-web",
|
|
mime_type="text/html; charset=utf-8",
|
|
kind="preview",
|
|
content=b"<p>x</p>",
|
|
)
|
|
s._tool_previews["c1"] = (descriptor, att)
|
|
# Assistant turn with one UNANSWERED call — the cancel shape.
|
|
s.messages.append(
|
|
turn_from_dict(
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "c1",
|
|
"type": "function",
|
|
"function": {"name": "open_preview", "arguments": "{}"},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
s._msg_tokens.append(1)
|
|
|
|
saved = {}
|
|
monkeypatch.setattr(
|
|
"turnstone.core.session.save_message",
|
|
lambda ws, role, content, name, **kw: (
|
|
saved.update({"meta": kw.get("meta"), "row": 42}) or 42
|
|
),
|
|
)
|
|
persisted = {}
|
|
monkeypatch.setattr(
|
|
ChatSession,
|
|
"_persist_attachment_refs",
|
|
lambda self, row_id, atts, origin="upload", ws_id=None: persisted.update(
|
|
{
|
|
"row": row_id,
|
|
"ids": [a.attachment_id for a in atts],
|
|
"origin": origin,
|
|
"ws_id": ws_id,
|
|
}
|
|
),
|
|
)
|
|
|
|
s._synthesize_cancelled_results("Cancelled by user.")
|
|
|
|
# Side channel drained; descriptor + blob committed with the turn.
|
|
assert "c1" not in s._tool_previews
|
|
meta = _json.loads(saved["meta"])
|
|
assert meta["preview"] == descriptor
|
|
assert meta["effect_status"] == "unknown"
|
|
assert persisted == {"row": 42, "ids": ["abc"], "origin": "tool", "ws_id": "ws-1"}
|
|
# The in-memory synthesized turn carries the descriptor too.
|
|
tool_turns = [t for t in s.messages if isinstance(t, Turn) and t.role is Role.TOOL]
|
|
assert tool_turns and tool_turns[-1].meta.extra.get("preview") == descriptor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tools.allow_private_network — the self-hoster opt-in (admin Settings → Tools)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowPrivateNetwork:
|
|
def test_screen_public_url_passes(self):
|
|
from turnstone.core.session import _screen_tool_url
|
|
|
|
err, private, _block = _screen_tool_url("https://93.184.216.34/x", False)
|
|
assert err is None and private is False
|
|
|
|
def test_screen_private_blocked_with_discoverable_hint(self):
|
|
from turnstone.core.session import _screen_tool_url
|
|
|
|
err, private, _block = _screen_tool_url("http://10.0.0.7/grafana", False)
|
|
assert err is not None and private is False
|
|
# The refusal teaches the knob (mirrors the oidc opt-in hint pattern).
|
|
assert "tools.allow_private_network" in err
|
|
assert "Settings" in err
|
|
|
|
def test_screen_private_allowed_when_opted_in(self):
|
|
from turnstone.core.session import _screen_tool_url
|
|
|
|
err, private, _block = _screen_tool_url("http://10.0.0.7/grafana", True)
|
|
assert err is None and private is True
|
|
|
|
def test_screen_invalid_url_never_hints(self):
|
|
from turnstone.core.session import _screen_tool_url
|
|
|
|
err, private, _block = _screen_tool_url("http://", True)
|
|
assert err is not None and private is False
|
|
assert "allow_private_network" not in err
|
|
|
|
def test_bare_session_defaults_strict(self):
|
|
# No ConfigStore (CLI / eval surface) → no admin opted in → strict.
|
|
s = _make_session()
|
|
assert s._allow_private_network() is False
|
|
|
|
def test_prepare_web_fetch_private_opted_in(self, monkeypatch):
|
|
s = _make_session()
|
|
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
|
|
item = s._prepare_web_fetch(
|
|
"c1", {"url": "http://192.168.1.50:3000/d/home", "question": "what is shown?"}
|
|
)
|
|
assert "error" not in item
|
|
assert item["needs_approval"] is True # the human gate stays
|
|
assert "(private network)" in item["header"]
|
|
assert item["allow_private_origin"] is True
|
|
|
|
def test_prepare_open_preview_private_opted_in(self, monkeypatch):
|
|
s = _make_session()
|
|
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
|
|
item = s._prepare_open_preview("c1", {"target": "http://192.168.1.50:3000/d/home"})
|
|
assert "error" not in item
|
|
assert item["needs_approval"] is True
|
|
assert "(private network)" in item["header"]
|
|
assert item["allow_private_origin"] is True
|
|
|
|
def test_prepare_private_still_blocked_by_default(self, monkeypatch):
|
|
s = _make_session()
|
|
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: False)
|
|
for prepare, args in (
|
|
(s._prepare_web_fetch, {"url": "http://10.0.0.7/x", "question": "q"}),
|
|
(s._prepare_open_preview, {"target": "http://10.0.0.7/x"}),
|
|
):
|
|
item = prepare("c1", args)
|
|
assert "error" in item
|
|
assert "tools.allow_private_network" in item["error"]
|
|
|
|
def test_executor_passes_private_origin_to_guard(self, monkeypatch):
|
|
s = _make_session()
|
|
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
|
|
seen = {}
|
|
|
|
def _capture(url, **kw):
|
|
seen.update(kw, url=url)
|
|
return _fake_response(url, b"<html><head></head><body>x</body></html>", "text/html")
|
|
|
|
monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _capture)
|
|
item = s._prepare_open_preview("c1", {"target": "http://10.0.0.7/status"})
|
|
s._exec_open_preview(item)
|
|
assert seen["allow_private_origin"] is True
|
|
|
|
def test_guard_permits_private_hops_for_an_approved_private_origin(self, monkeypatch):
|
|
"""Screening is not skipped for a private origin — it is WIDENED.
|
|
|
|
The operator approved a private URL, so the PRIVATE lane is acceptable
|
|
on this chain; every hop is still classified. (These are IP literals,
|
|
so the real screen resolves them without touching the network.)
|
|
"""
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://10.0.0.7/a": _FakeHop(302, {"location": "http://10.0.0.8/b"}),
|
|
"http://10.0.0.8/b": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
resp = fetch_with_ssrf_guard("http://10.0.0.7/a", timeout=5, allow_private_origin=True)
|
|
assert resp.status_code == 200
|
|
assert _FakeClient.calls == ["http://10.0.0.7/a", "http://10.0.0.8/b"]
|
|
|
|
def test_private_hop_refused_without_the_private_origin_flag(self, monkeypatch):
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {"http://10.0.0.7/a": _FakeHop(200, {})}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
with pytest.raises(ValueError, match="private/internal"):
|
|
fetch_with_ssrf_guard("http://10.0.0.7/a", timeout=5)
|
|
assert _FakeClient.calls == []
|
|
|
|
def test_public_bounce_cannot_return_to_the_origin_host(self, monkeypatch):
|
|
"""``private -> public -> back to the origin`` must not be re-admitted.
|
|
|
|
An origin-host exemption (added to let a dual-stack host redirect to
|
|
itself) made this reachable: the permission was keyed on the hostname
|
|
and never cleared, so a public hop could send the fetcher back to the
|
|
approved host at a path of its choosing. Mixed-record origins are now
|
|
refused before the fetch instead, so the guard needs no exemption.
|
|
"""
|
|
import socket
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://home.example/": _FakeHop(302, {"location": "http://attacker.example/"}),
|
|
"http://attacker.example/": _FakeHop(302, {"location": "http://home.example/admin"}),
|
|
"http://home.example/admin": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
|
|
def _resolve(host, port=None, *a, **kw):
|
|
if host == "home.example":
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.5", 0)),
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
socket.IPPROTO_TCP,
|
|
"",
|
|
("93.184.216.34", 0),
|
|
),
|
|
]
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("93.184.216.34", 0))
|
|
]
|
|
|
|
monkeypatch.setattr("socket.getaddrinfo", _resolve)
|
|
with pytest.raises(ValueError, match="private/internal"):
|
|
fetch_with_ssrf_guard("http://home.example/", timeout=5, allow_private_origin=True)
|
|
assert _FakeClient.calls == ["http://home.example/", "http://attacker.example/"]
|
|
|
|
def test_dual_stack_origin_cannot_redirect_to_another_private_host(self, monkeypatch):
|
|
"""The approval covers that host, not the rest of the network."""
|
|
import socket
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://grafana.home.arpa/": _FakeHop(302, {"location": "http://10.0.0.1/admin"}),
|
|
"http://10.0.0.1/admin": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
infos = [
|
|
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.5", 0)),
|
|
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("93.184.216.34", 0)),
|
|
]
|
|
monkeypatch.setattr("socket.getaddrinfo", lambda *a, **kw: infos)
|
|
with pytest.raises(ValueError, match="private/internal"):
|
|
fetch_with_ssrf_guard("http://grafana.home.arpa/", timeout=5, allow_private_origin=True)
|
|
assert _FakeClient.calls == ["http://grafana.home.arpa/"]
|
|
|
|
def test_mixed_record_hop_revokes_the_private_permission(self, monkeypatch):
|
|
"""A hop that merely CONTAINS a private record must not keep the permission.
|
|
|
|
The revocation used to key on the folded lane, so a hop resolving to
|
|
both a private and an attacker-controlled public record folded to
|
|
PRIVATE, was fetched, and did NOT revoke — letting the attacker's
|
|
server steer the next hop back into private space.
|
|
"""
|
|
import pytest
|
|
|
|
from turnstone.core import web
|
|
from turnstone.core.ip_classify import AddressLane
|
|
from turnstone.core.web import UrlScreen, fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://10.0.0.7/a": _FakeHop(302, {"location": "http://mixed.example/b"}),
|
|
"http://mixed.example/b": _FakeHop(302, {"location": "http://10.0.0.1/admin"}),
|
|
"http://10.0.0.1/admin": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
|
|
real = web.screen_url
|
|
|
|
def _screen(url):
|
|
if "mixed.example" in url:
|
|
# Worst lane PRIVATE, but not wholly private.
|
|
return UrlScreen(AddressLane.PRIVATE, "Blocked: private/internal", False)
|
|
return real(url)
|
|
|
|
monkeypatch.setattr("turnstone.core.web.screen_url", _screen)
|
|
with pytest.raises(ValueError, match="private/internal"):
|
|
fetch_with_ssrf_guard("http://10.0.0.7/a", timeout=5, allow_private_origin=True)
|
|
assert _FakeClient.calls == ["http://10.0.0.7/a", "http://mixed.example/b"]
|
|
|
|
def test_public_bounce_revokes_the_private_permission(self, monkeypatch):
|
|
"""``private -> public -> private`` must not reach the final hop.
|
|
|
|
The operator approved their own hosts, not whatever a public site
|
|
picks next. Without this, a LAN page serving attacker-authored content
|
|
could steer the fetcher into internal endpoints of the attacker's
|
|
choosing and hand back the response.
|
|
"""
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://10.0.0.7/wiki": _FakeHop(302, {"location": "http://93.184.216.34/"}),
|
|
"http://93.184.216.34/": _FakeHop(302, {"location": "http://10.0.0.1/admin"}),
|
|
"http://10.0.0.1/admin": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
with pytest.raises(ValueError, match="private/internal"):
|
|
fetch_with_ssrf_guard("http://10.0.0.7/wiki", timeout=5, allow_private_origin=True)
|
|
assert _FakeClient.calls == ["http://10.0.0.7/wiki", "http://93.184.216.34/"]
|
|
|
|
def test_guard_still_blocks_metadata_hop_from_a_private_origin(self, monkeypatch):
|
|
"""Approving a private origin says "this is my network" — not "and the IMDS".
|
|
|
|
The PRIVATE lane is not re-screened for an approved private origin (see
|
|
the test above), but the NEVER lane is absolute: a LAN host that is
|
|
compromised, or simply serving attacker-authored content, must not be
|
|
able to bounce the fetcher into the cloud metadata endpoint.
|
|
"""
|
|
import pytest
|
|
|
|
from turnstone.core.web import fetch_with_ssrf_guard
|
|
|
|
_FakeClient.calls = []
|
|
_FakeClient.table = {
|
|
"http://10.0.0.7/a": _FakeHop(
|
|
302, {"location": "http://169.254.169.254/latest/meta-data/"}
|
|
),
|
|
"http://169.254.169.254/latest/meta-data/": _FakeHop(200, {}),
|
|
}
|
|
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
|
|
|
|
with pytest.raises(ValueError, match="link-local"):
|
|
fetch_with_ssrf_guard("http://10.0.0.7/a", timeout=5, allow_private_origin=True)
|
|
assert _FakeClient.calls == ["http://10.0.0.7/a"], "metadata hop was never issued"
|
|
|
|
def test_registry_entry_shape(self):
|
|
from turnstone.core.settings_registry import SETTINGS
|
|
|
|
d = SETTINGS["tools.allow_private_network"]
|
|
assert d.type == "bool"
|
|
assert d.default is False
|
|
assert d.section == "tools"
|
|
assert d.help # the admin form renders this — it must explain the caveat
|