mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat!: the token on every route, Secure Config Only goes (step 4b)
Under Secured every HTTP route but the public ones needs the token (docs/adr/0006-two-security-modes-eleven-http-routes.md): - isPublicRoute, isWebAsset and originMatchesHost in src/HttpAccess.h, with native tests. - One middleware checks the token. It runs once the body is in, so /restore, /updateFirmware and /updateApplication check it on their first chunk and drop a refused upload. - Static serving answers the web UI's files only: shades.cfg and shades.tmp are no longer served. - The wildcard CORS headers go; WEB_CORS turns them back on for UI work from file://. - The socket refuses a handshake whose Origin doesn't name its Host. - "Secure Config Only" goes: a device set to it is Secured, and /loginContext drops permissions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
13ec38823b
commit
ecb0260f61
16 changed files with 392 additions and 54 deletions
|
|
@ -79,14 +79,15 @@ class Schema:
|
|||
|
||||
|
||||
class ProtoClient:
|
||||
def __init__(self, host, port=80, path="/ws", params=None, timeout=10):
|
||||
def __init__(self, host, port=80, path="/ws", params=None, timeout=10, headers=None):
|
||||
self.host = host
|
||||
query = ("?" + urllib.parse.urlencode(params)) if params else ""
|
||||
self.sock = socket.create_connection((host, port), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
extra = "".join(f"{k}: {v}\r\n" for k, v in (headers or {}).items())
|
||||
self.sock.sendall((
|
||||
f"GET {path}{query} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n{extra}\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(1024)
|
||||
|
|
|
|||
127
test/e2e/test_routes.py
Normal file
127
test/e2e/test_routes.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""The HTTP routes and the socket's Origin check under the device's security
|
||||
mode (docs/adr/0006-two-security-modes-eleven-http-routes.md).
|
||||
|
||||
Like test_auth, it tests the mode it finds: pass --password or --pin for a
|
||||
Secured device.
|
||||
"""
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from proto_client import ProtoClient, heap
|
||||
|
||||
# Every route that needs the token under Secured, sent without a body or
|
||||
# parameters, so a route that let one through would still change nothing.
|
||||
TOKEN_ROUTES = [
|
||||
("GET", "/backup"),
|
||||
("GET", "/shadeCommand"),
|
||||
("GET", "/groupCommand"),
|
||||
("GET", "/tiltCommand"),
|
||||
("PUT", "/shadeCommand"),
|
||||
("PUT", "/groupCommand"),
|
||||
("PUT", "/tiltCommand"),
|
||||
]
|
||||
PUBLIC_ROUTES = ["/", "/index.html", "/index.js", "/loginContext", "/upnp.xml"]
|
||||
SHADE_FILES = ["/shades.cfg", "/shades.tmp"]
|
||||
|
||||
|
||||
def http(host, path, method="GET", body=None, headers=None):
|
||||
"""The status and headers of a request, refusals included."""
|
||||
req = urllib.request.Request(f"http://{host}{path}", data=body, method=method, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
r.read()
|
||||
return r.status, r.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
e.read()
|
||||
return e.code, e.headers
|
||||
|
||||
|
||||
def upload(host, path, headers=None):
|
||||
"""Posts a small file that isn't a backup or an image."""
|
||||
boundary = "e2e-upload"
|
||||
body = (
|
||||
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"e2e.bin\"\r\n"
|
||||
"Content-Type: application/octet-stream\r\n\r\n").encode() + b"\0" * 256 + f"\r\n--{boundary}--\r\n".encode()
|
||||
return http(host, path, "POST", body, {"Content-Type": f"multipart/form-data; boundary={boundary}", **(headers or {})})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path", TOKEN_ROUTES)
|
||||
def test_secured_refuses_a_route_without_a_token(host, secured, method, path):
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
body = b"{}" if method == "PUT" else None
|
||||
status, _ = http(host, path, method, body, {"Content-Type": "application/json"} if body else None)
|
||||
assert status == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path", TOKEN_ROUTES)
|
||||
def test_secured_refuses_a_route_with_a_wrong_token(host, secured, method, path):
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
body = b"{}" if method == "PUT" else None
|
||||
headers = {"apikey": "0" * 64}
|
||||
if body:
|
||||
headers["Content-Type"] = "application/json"
|
||||
status, _ = http(host, path, method, body, headers)
|
||||
assert status == 401
|
||||
|
||||
|
||||
def test_secured_accepts_the_token_on_a_route(host, secured, token):
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
status, _ = http(host, "/backup", headers={"apikey": token})
|
||||
assert status == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/restore", "/updateFirmware"])
|
||||
def test_secured_drops_an_upload_without_a_token(host, secured, token, path):
|
||||
"""The token check runs once the body is in, so the uploads check it on their first chunk.
|
||||
|
||||
An /updateFirmware that got through would stop the radio and reboot the
|
||||
device once the connection closed. /updateApplication isn't sent: one
|
||||
that got through would write over the file system.
|
||||
"""
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
before = heap(host, token)
|
||||
status, _ = upload(host, path)
|
||||
assert status == 401
|
||||
after = heap(host, token)
|
||||
assert after["uptime"] >= before["uptime"], "the device rebooted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PUBLIC_ROUTES)
|
||||
def test_public_routes_need_no_token(host, path):
|
||||
status, _ = http(host, path)
|
||||
assert status == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", SHADE_FILES)
|
||||
def test_the_shade_files_are_not_served(host, path):
|
||||
"""Their rolling codes leave the device only through /backup."""
|
||||
status, _ = http(host, path)
|
||||
assert status in (401, 404)
|
||||
|
||||
|
||||
def test_replies_carry_no_cors_headers(host):
|
||||
_, headers = http(host, "/loginContext", headers={"Origin": "http://example.com"})
|
||||
assert headers.get("Access-Control-Allow-Origin") is None
|
||||
|
||||
|
||||
def test_a_socket_from_another_site_is_refused(host, token):
|
||||
params = {"apikey": token} if token else None
|
||||
c = ProtoClient(host, params=params, headers={"Origin": "http://example.com"})
|
||||
assert c.status == 401
|
||||
|
||||
|
||||
def test_a_socket_from_the_page_the_device_served_is_accepted(host, token):
|
||||
# ProtoClient's Host names the port, and so does an Origin that matches it.
|
||||
params = {"apikey": token} if token else None
|
||||
c = ProtoClient(host, params=params, headers={"Origin": f"http://{host}:80"})
|
||||
assert c.status == 101
|
||||
c.close()
|
||||
# The device frees the slot when it sees the disconnect.
|
||||
time.sleep(0.5)
|
||||
138
test/test_httpaccess/test_main.cpp
Normal file
138
test/test_httpaccess/test_main.cpp
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#include <unity.h>
|
||||
#include "HttpAccess.h"
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
/*********************************************************************
|
||||
* Web assets
|
||||
********************************************************************/
|
||||
static void test_the_web_ui_files_are_web_assets() {
|
||||
TEST_ASSERT_TRUE(isWebAsset("/"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/index.html"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/index.js"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/app.css"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/favicon.png"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/apple-icon.png"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/icon.svg"));
|
||||
TEST_ASSERT_TRUE(isWebAsset("/appversion"));
|
||||
}
|
||||
static void test_the_shade_files_are_not_web_assets() {
|
||||
TEST_ASSERT_FALSE(isWebAsset("/shades.cfg"));
|
||||
TEST_ASSERT_FALSE(isWebAsset("/shades.tmp"));
|
||||
}
|
||||
static void test_a_compressed_file_is_not_asked_for_by_name() {
|
||||
// serveStatic finds index.js.gz for /index.js.
|
||||
TEST_ASSERT_FALSE(isWebAsset("/index.js.gz"));
|
||||
}
|
||||
static void test_a_type_alone_is_not_a_web_asset() {
|
||||
TEST_ASSERT_FALSE(isWebAsset("/.js"));
|
||||
TEST_ASSERT_FALSE(isWebAsset(".js"));
|
||||
TEST_ASSERT_FALSE(isWebAsset("/js"));
|
||||
}
|
||||
static void test_a_path_leaving_the_root_is_not_a_web_asset() {
|
||||
TEST_ASSERT_FALSE(isWebAsset("/../shades.cfg.js"));
|
||||
TEST_ASSERT_FALSE(isWebAsset("/x/../index.js"));
|
||||
}
|
||||
static void test_a_missing_or_relative_path_is_not_a_web_asset() {
|
||||
TEST_ASSERT_FALSE(isWebAsset(nullptr));
|
||||
TEST_ASSERT_FALSE(isWebAsset(""));
|
||||
TEST_ASSERT_FALSE(isWebAsset("index.html"));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Public routes
|
||||
********************************************************************/
|
||||
static void test_logging_in_and_ssdp_are_public() {
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/loginContext", false));
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/login", false));
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/upnp.xml", false));
|
||||
}
|
||||
static void test_the_socket_checks_its_own_token() {
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/ws", false));
|
||||
}
|
||||
static void test_the_web_ui_is_public() {
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/", false));
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/index.js", false));
|
||||
}
|
||||
static void test_the_other_routes_need_the_token() {
|
||||
static const char *const routes[] = {"/backup", "/restore", "/updateFirmware", "/updateApplication",
|
||||
"/shadeCommand", "/groupCommand", "/tiltCommand"};
|
||||
for(const char *route : routes) TEST_ASSERT_FALSE_MESSAGE(isPublicRoute(route, false), route);
|
||||
}
|
||||
static void test_an_unknown_path_needs_the_token() {
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/shades.cfg", false));
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/controller", false));
|
||||
TEST_ASSERT_FALSE(isPublicRoute(nullptr, false));
|
||||
}
|
||||
static void test_a_route_differing_in_case_or_length_needs_the_token() {
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/Login", false));
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/login/", false));
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/logins", false));
|
||||
TEST_ASSERT_FALSE(isPublicRoute("/ws/", false));
|
||||
}
|
||||
static void test_a_preflight_is_public() {
|
||||
TEST_ASSERT_TRUE(isPublicRoute("/backup", true));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Origin and Host
|
||||
********************************************************************/
|
||||
static void test_the_page_the_device_served_matches() {
|
||||
TEST_ASSERT_TRUE(originMatchesHost("http://192.168.1.20", "192.168.1.20"));
|
||||
TEST_ASSERT_TRUE(originMatchesHost("http://espsomfyrts.local", "espsomfyrts.local"));
|
||||
TEST_ASSERT_TRUE(originMatchesHost("http://192.168.1.20:8000", "192.168.1.20:8000"));
|
||||
}
|
||||
static void test_https_behind_a_proxy_matches() {
|
||||
TEST_ASSERT_TRUE(originMatchesHost("https://somfy.example.com", "somfy.example.com"));
|
||||
}
|
||||
static void test_host_names_match_whatever_their_case() {
|
||||
TEST_ASSERT_TRUE(originMatchesHost("http://ESPSomfyRTS.local", "espsomfyrts.local"));
|
||||
TEST_ASSERT_TRUE(originMatchesHost("HTTP://192.168.1.20", "192.168.1.20"));
|
||||
}
|
||||
static void test_another_site_does_not_match() {
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://evil.example", "192.168.1.20"));
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20.evil.example", "192.168.1.20"));
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.2", "192.168.1.20"));
|
||||
}
|
||||
static void test_another_port_does_not_match() {
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20:8000", "192.168.1.20"));
|
||||
}
|
||||
static void test_a_page_opened_from_a_file_does_not_match() {
|
||||
TEST_ASSERT_FALSE(originMatchesHost("null", "192.168.1.20"));
|
||||
TEST_ASSERT_FALSE(originMatchesHost("file://", "192.168.1.20"));
|
||||
}
|
||||
static void test_an_origin_without_a_host_does_not_match() {
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20", ""));
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://", ""));
|
||||
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20", nullptr));
|
||||
}
|
||||
static void test_a_client_that_is_not_a_browser_sends_no_origin() {
|
||||
TEST_ASSERT_TRUE(originMatchesHost(nullptr, "192.168.1.20"));
|
||||
}
|
||||
|
||||
int main(int, char **) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_the_web_ui_files_are_web_assets);
|
||||
RUN_TEST(test_the_shade_files_are_not_web_assets);
|
||||
RUN_TEST(test_a_compressed_file_is_not_asked_for_by_name);
|
||||
RUN_TEST(test_a_type_alone_is_not_a_web_asset);
|
||||
RUN_TEST(test_a_path_leaving_the_root_is_not_a_web_asset);
|
||||
RUN_TEST(test_a_missing_or_relative_path_is_not_a_web_asset);
|
||||
RUN_TEST(test_logging_in_and_ssdp_are_public);
|
||||
RUN_TEST(test_the_socket_checks_its_own_token);
|
||||
RUN_TEST(test_the_web_ui_is_public);
|
||||
RUN_TEST(test_the_other_routes_need_the_token);
|
||||
RUN_TEST(test_an_unknown_path_needs_the_token);
|
||||
RUN_TEST(test_a_route_differing_in_case_or_length_needs_the_token);
|
||||
RUN_TEST(test_a_preflight_is_public);
|
||||
RUN_TEST(test_the_page_the_device_served_matches);
|
||||
RUN_TEST(test_https_behind_a_proxy_matches);
|
||||
RUN_TEST(test_host_names_match_whatever_their_case);
|
||||
RUN_TEST(test_another_site_does_not_match);
|
||||
RUN_TEST(test_another_port_does_not_match);
|
||||
RUN_TEST(test_a_page_opened_from_a_file_does_not_match);
|
||||
RUN_TEST(test_an_origin_without_a_host_does_not_match);
|
||||
RUN_TEST(test_a_client_that_is_not_a_browser_sends_no_origin);
|
||||
return UNITY_END();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue