mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 09:39:46 -05:00
Closes the DNS rebinding hole, which the origin guard structurally cannot: under rebinding the attacker's page keeps its own name, so Origin and Host agree with each other and the origin guard trusts them both, while the address behind that name is the victim's Spoolman instance. The default allows everything a stranger cannot point at someone else's machine -- any IP literal, any single-label name, and the non-registrable suffixes -- because what the attack needs is a registrable public domain. That leaves a reverse proxy on a real domain, which cannot be told apart from the attack and so must declare itself via SPOOLMAN_ALLOWED_HOSTS.
193 lines
6.7 KiB
Python
193 lines
6.7 KiB
Python
"""Integration tests: cross-origin requests, and requests addressed to a foreign host, are refused.
|
|
|
|
Spoolman has no authentication, so without an origin check any website the user happens to visit
|
|
can make their browser write to a Spoolman instance it can reach -- a cross-origin form post
|
|
needs no CORS preflight -- and can open a websocket to it, since websockets are exempt from CORS
|
|
entirely.
|
|
|
|
The host check closes what the origin check cannot: under DNS rebinding the attacker's page keeps
|
|
its own name, so Origin and Host agree with each other and the origin check trusts them both.
|
|
|
|
The server under test runs with no SPOOLMAN_CORS_ORIGIN, so the origin policy is "same origin
|
|
only", and with SPOOLMAN_ALLOWED_HOSTS=spoolman.example.com so that both sides of the host check
|
|
are exercised.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
import pytest
|
|
from websockets.asyncio.client import connect
|
|
from websockets.exceptions import InvalidStatus
|
|
|
|
from .conftest import URL
|
|
|
|
WS_URL = URL.replace("http://", "ws://", 1)
|
|
|
|
OWN_HOST = urlsplit(URL).netloc
|
|
OWN_ORIGIN = URL
|
|
EVIL_ORIGIN = "https://evil.example"
|
|
|
|
|
|
def test_cross_origin_write_is_refused():
|
|
"""A write from a foreign origin must be refused, and must not change anything."""
|
|
before = httpx.get(f"{URL}/api/v1/setting/currency")
|
|
before.raise_for_status()
|
|
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/setting/currency",
|
|
json='"HAX"',
|
|
headers={"Origin": EVIL_ORIGIN},
|
|
)
|
|
assert result.status_code == 403
|
|
|
|
after = httpx.get(f"{URL}/api/v1/setting/currency")
|
|
after.raise_for_status()
|
|
assert after.json() == before.json()
|
|
|
|
|
|
def test_same_origin_write_is_allowed():
|
|
"""The web UI's own origin must keep working."""
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/setting/currency",
|
|
json='"SEK"',
|
|
headers={"Origin": OWN_ORIGIN},
|
|
)
|
|
result.raise_for_status()
|
|
assert result.json()["value"] == '"SEK"'
|
|
|
|
# Cleanup
|
|
httpx.post(f"{URL}/api/v1/setting/currency", json="").raise_for_status()
|
|
|
|
|
|
def test_write_without_an_origin_header_is_allowed():
|
|
"""Moonraker, OctoPrint, Home Assistant and curl send no Origin and must not be affected."""
|
|
result = httpx.post(f"{URL}/api/v1/setting/currency", json='"SEK"')
|
|
result.raise_for_status()
|
|
|
|
# Cleanup
|
|
httpx.post(f"{URL}/api/v1/setting/currency", json="").raise_for_status()
|
|
|
|
|
|
def test_cross_origin_read_is_allowed():
|
|
"""Only state-changing methods are guarded; the same-origin policy covers reads."""
|
|
result = httpx.get(f"{URL}/api/v1/spool", headers={"Origin": EVIL_ORIGIN})
|
|
assert result.status_code == 200
|
|
|
|
|
|
def test_cross_origin_delete_is_refused():
|
|
result = httpx.delete(f"{URL}/api/v1/spool/1", headers={"Origin": EVIL_ORIGIN})
|
|
assert result.status_code == 403
|
|
|
|
|
|
def test_cross_origin_backup_is_refused():
|
|
"""A bodyless form post to /backup would otherwise rotate away every restore point."""
|
|
result = httpx.post(f"{URL}/api/v1/backup", headers={"Origin": EVIL_ORIGIN})
|
|
assert result.status_code == 403
|
|
|
|
|
|
def test_forwarded_host_is_accepted():
|
|
"""A reverse proxy that rewrites Host must not have its own web UI refused."""
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/setting/currency",
|
|
json='"SEK"',
|
|
headers={
|
|
"Host": "127.0.0.1:1234",
|
|
"Origin": "https://spoolman.example.com",
|
|
"X-Forwarded-Host": "spoolman.example.com",
|
|
},
|
|
)
|
|
assert result.status_code == 200
|
|
|
|
# Cleanup
|
|
httpx.post(f"{URL}/api/v1/setting/currency", json="").raise_for_status()
|
|
|
|
|
|
def test_forwarded_host_does_not_admit_a_foreign_origin():
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/setting/currency",
|
|
json='"HAX"',
|
|
headers={
|
|
"Host": "127.0.0.1:1234",
|
|
"Origin": EVIL_ORIGIN,
|
|
"X-Forwarded-Host": "spoolman.example.com",
|
|
},
|
|
)
|
|
assert result.status_code == 403
|
|
|
|
|
|
def test_rebound_host_is_refused():
|
|
"""A public domain the operator never declared, which is what rebinding delivers."""
|
|
result = httpx.get(f"{URL}/api/v1/spool", headers={"Host": "evil.example"})
|
|
assert result.status_code == 400
|
|
|
|
|
|
def test_rebound_host_is_refused_even_with_a_matching_origin():
|
|
"""The origin check trusts this pair; only the host check refuses it."""
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/setting/currency",
|
|
json='"HAX"',
|
|
headers={"Host": "evil.example", "Origin": "https://evil.example"},
|
|
)
|
|
assert result.status_code == 400
|
|
|
|
|
|
def test_rebound_forwarded_host_is_refused():
|
|
result = httpx.get(f"{URL}/api/v1/spool", headers={"X-Forwarded-Host": "evil.example"})
|
|
assert result.status_code == 400
|
|
|
|
|
|
def test_own_host_is_allowed():
|
|
"""The tester reaches Spoolman by its single-label compose service name."""
|
|
result = httpx.get(f"{URL}/api/v1/spool", headers={"Host": OWN_HOST})
|
|
assert result.status_code == 200
|
|
|
|
|
|
def test_configured_host_is_allowed():
|
|
result = httpx.get(f"{URL}/api/v1/spool", headers={"Host": "spoolman.example.com"})
|
|
assert result.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebound_websocket_is_refused():
|
|
"""Note the client library sends its own Host, so the rebound name goes in X-Forwarded-Host.
|
|
|
|
Both are checked, and uvicorn renders any close sent before the handshake is accepted as an
|
|
HTTP 403 whatever close code we used -- the same status the origin guard produces.
|
|
"""
|
|
with pytest.raises(InvalidStatus) as excinfo:
|
|
async with connect(
|
|
f"{WS_URL}/api/v1/spool",
|
|
additional_headers={"X-Forwarded-Host": "evil.example"},
|
|
):
|
|
pass
|
|
assert excinfo.value.response.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cross_origin_websocket_is_refused():
|
|
"""Websockets bypass CORS, so this is the one channel that leaks reads cross-origin."""
|
|
with pytest.raises(InvalidStatus) as excinfo:
|
|
async with connect(f"{WS_URL}/api/v1/spool", additional_headers={"Origin": EVIL_ORIGIN}):
|
|
pass
|
|
assert excinfo.value.response.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_origin_websocket_is_allowed():
|
|
async with connect(f"{WS_URL}/api/v1/spool", additional_headers={"Origin": OWN_ORIGIN}) as ws:
|
|
await asyncio.sleep(0.2)
|
|
await ws.send("ping")
|
|
assert json.loads(await asyncio.wait_for(ws.recv(), timeout=5))["status"] == "healthy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_without_an_origin_header_is_allowed():
|
|
"""Non-browser websocket consumers must keep working."""
|
|
async with connect(f"{WS_URL}/api/v1/spool") as ws:
|
|
await asyncio.sleep(0.2)
|
|
await ws.send("ping")
|
|
assert json.loads(await asyncio.wait_for(ws.recv(), timeout=5))["status"] == "healthy"
|