From f5c549f66985c8ec8c19077f3f52538f6cdf7117 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 8 Jun 2026 10:43:30 +0200 Subject: [PATCH] test: Add test for login flow in WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify: - Unauthenticated GET / -> 303 to /login - Bad-password rejection -> no session is created - Session cookie is flagged HttpOnly - Authenticated GET /interfaces -> 200 - with htmx-config meta tag check - State-changing POST without CSRF token -> 403 - POST with X-CSRF-Token header -> succeeds - After logout the session no longer grants access The cookie-preserving WebUI helper class also folds in the IPv6 link-local zone-id workaround (re-encode %25 back to %) that infamy.restconf already uses. Signed-off-by: Mattias Walström --- test/case/services/all.yaml | 3 + test/case/services/webui/all.yaml | 3 + .../services/webui/login_csrf/Readme.adoc | 1 + test/case/services/webui/login_csrf/test.adoc | 45 +++++ test/case/services/webui/login_csrf/test.py | 170 ++++++++++++++++++ .../services/webui/login_csrf/topology.dot | 22 +++ .../services/webui/login_csrf/topology.svg | 33 ++++ test/case/webui/basic/test.py | 86 +++++++++ 8 files changed, 363 insertions(+) create mode 100644 test/case/services/webui/all.yaml create mode 120000 test/case/services/webui/login_csrf/Readme.adoc create mode 100644 test/case/services/webui/login_csrf/test.adoc create mode 100755 test/case/services/webui/login_csrf/test.py create mode 100644 test/case/services/webui/login_csrf/topology.dot create mode 100644 test/case/services/webui/login_csrf/topology.svg create mode 100644 test/case/webui/basic/test.py diff --git a/test/case/services/all.yaml b/test/case/services/all.yaml index eef4de21..155de1b2 100644 --- a/test/case/services/all.yaml +++ b/test/case/services/all.yaml @@ -7,3 +7,6 @@ - name: SSH suite: ssh/all.yaml + +- name: Web UI + suite: webui/all.yaml diff --git a/test/case/services/webui/all.yaml b/test/case/services/webui/all.yaml new file mode 100644 index 00000000..951e6b02 --- /dev/null +++ b/test/case/services/webui/all.yaml @@ -0,0 +1,3 @@ +--- +- name: Web UI Login and CSRF Protection + case: login_csrf/test.py diff --git a/test/case/services/webui/login_csrf/Readme.adoc b/test/case/services/webui/login_csrf/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/services/webui/login_csrf/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/services/webui/login_csrf/test.adoc b/test/case/services/webui/login_csrf/test.adoc new file mode 100644 index 00000000..b47943a2 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.adoc @@ -0,0 +1,45 @@ +=== Web UI Login and CSRF Protection + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/services/webui/login_csrf] + +==== Description + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. + +==== Topology + +image::topology.svg[Web UI Login and CSRF Protection topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Wait for the web UI to come up +. Unauthenticated access to a protected page redirects to /login +. Login with a bad password is rejected +. Login with correct credentials establishes a session +. Session cookie is flagged HttpOnly +. Authenticated page load succeeds +. State-changing POST without CSRF token is rejected +. State-changing POST with CSRF token succeeds (logout) +. After logout the session no longer grants access + + diff --git a/test/case/services/webui/login_csrf/test.py b/test/case/services/webui/login_csrf/test.py new file mode 100755 index 00000000..27c843b3 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Web UI login and CSRF protection + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. +""" +import re +import warnings + +import requests +from urllib3.exceptions import InsecureRequestWarning + +import infamy +from infamy import until + +# The device uses a self-signed certificate; silence the verify=False noise. +warnings.simplefilter("ignore", InsecureRequestWarning) + + +class WebUI: + """Cookie-preserving HTTP client for the web UI, link-local aware.""" + + def __init__(self, host, username, password): + self.base = f"https://[{host}]" + self.username = username + self.password = password + self.session = requests.Session() + + def _send(self, method, path, **kwargs): + req = requests.Request(method, f"{self.base}{path}", **kwargs) + prepared = self.session.prepare_request(req) + # Undo urllib3's percent-encoding of the IPv6 zone id separator. + prepared.url = re.sub(r"%25", "%", prepared.url) + # http.cookiejar's host normalisation for IPv6 link-local URLs is + # inconsistent across Python versions — the csrf cookie set on + # GET /login can fail to match the same URL on the following POST, + # producing a 403 from csrfMiddleware that's awfully hard to + # diagnose. Rebuild the Cookie header from the jar directly so + # whatever we received gets sent back verbatim. + jar = "; ".join(f"{c.name}={c.value}" for c in self.session.cookies) + if jar: + prepared.headers["Cookie"] = jar + # Never auto-follow redirects: requests would rebuild the target URL + # internally and re-mangle the link-local zone id, breaking the + # connection. We assert on the 3xx directly instead. + return self.session.send(prepared, verify=False, allow_redirects=False) + + def get(self, path, **kwargs): + return self._send("GET", path, **kwargs) + + def post(self, path, **kwargs): + return self._send("POST", path, **kwargs) + + def csrf_token(self): + """Return the current CSRF token from the session cookie jar.""" + return self.session.cookies.get("csrf") + + def login(self): + """Fetch /login, scrape the CSRF token, and POST credentials. + + Returns the POST response. On success the server answers 303 to / + and sets the session cookie; on failure it re-renders /login (200) + with no session cookie. + """ + page = self.get("/login") + assert page.status_code == 200, f"GET /login: {page.status_code}" + m = re.search(r'name="csrf-token"\s+content="([^"]+)"', page.text) + assert m, "no csrf-token meta tag on /login" + token = m.group(1) + + return self.post("/login", data={ + "username": self.username, + "password": self.password, + "csrf": token, + }) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + # Attach over NETCONF only to learn the mgmt address and admin + # password; the web UI itself is exercised over plain HTTPS below. + target = env.attach("target", "mgmt", protocol="netconf") + host = target.location.host + password = target.location.password + + with test.step("Wait for the web UI to come up"): + def webui_ready(): + try: + r = WebUI(host, "admin", password).get("/login") + return r.status_code == 200 and "csrf-token" in r.text + except Exception: + return False + + until(webui_ready, attempts=60, interval=2) + + with test.step("Unauthenticated access to a protected page redirects to /login"): + anon = WebUI(host, "admin", password) + r = anon.get("/") + assert r.status_code == 303, f"want 303, got {r.status_code}" + assert r.headers.get("Location") == "/login", \ + f"want redirect to /login, got {r.headers.get('Location')}" + + with test.step("Login with a bad password is rejected"): + bad = WebUI(host, "admin", "definitely-not-the-password") + r = bad.login() + assert r.status_code == 200, \ + f"bad login should re-render /login (200), got {r.status_code}" + assert "session" not in bad.session.cookies, \ + "session cookie set despite wrong password" + # And a protected page is still denied. + r = bad.get("/") + assert r.status_code == 303, \ + f"bad login still authenticated? got {r.status_code}" + + with test.step("Login with correct credentials establishes a session"): + ui = WebUI(host, "admin", password) + r = ui.login() + assert r.status_code == 303, f"login should redirect (303), got {r.status_code}" + assert r.headers.get("Location") == "/", \ + f"login should redirect to /, got {r.headers.get('Location')}" + assert "session" in ui.session.cookies, "no session cookie after login" + + with test.step("Session cookie is flagged HttpOnly"): + sess = next(c for c in ui.session.cookies if c.name == "session") + assert sess.has_nonstandard_attr("HttpOnly"), \ + "session cookie missing HttpOnly flag" + + with test.step("Authenticated page load succeeds"): + r = ui.get("/interfaces") + assert r.status_code == 200, f"GET /interfaces: {r.status_code}" + # base.html ships an htmx-config meta tag on every authenticated page; + # its absence means the layout chrome didn't render even though we got 200. + assert 'name="htmx-config"' in r.text, "htmx-config meta missing from authenticated page" + + with test.step("State-changing POST without CSRF token is rejected"): + # Valid session cookie, but no X-CSRF-Token / csrf form field. + r = ui.post("/logout") + assert r.status_code == 403, \ + f"CSRF-less POST not rejected, got {r.status_code}" + + with test.step("State-changing POST with CSRF token succeeds (logout)"): + r = ui.post("/logout", headers={"X-CSRF-Token": ui.csrf_token()}) + assert r.status_code in (200, 204, 303), \ + f"logout failed, got {r.status_code}" + + with test.step("After logout the session no longer grants access"): + r = ui.get("/") + assert r.status_code == 303, \ + f"still authenticated after logout, got {r.status_code}" + + test.succeed() diff --git a/test/case/services/webui/login_csrf/topology.dot b/test/case/services/webui/login_csrf/topology.dot new file mode 100644 index 00000000..34a6b58b --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.dot @@ -0,0 +1,22 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] +} diff --git a/test/case/services/webui/login_csrf/topology.svg b/test/case/services/webui/login_csrf/topology.svg new file mode 100644 index 00000000..6fc6f47a --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + diff --git a/test/case/webui/basic/test.py b/test/case/webui/basic/test.py new file mode 100644 index 00000000..c8f02687 --- /dev/null +++ b/test/case/webui/basic/test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +WebUI login and logout + +Smoke test that the WebUI is reachable, accepts admin credentials, +serves the dashboard while authenticated, and clears the session on +logout. Pure HTTP — no browser framework — so we only catch backend +and plumbing regressions (nginx down, Go service crashed, TLS or +RESTCONF auth broken, CSRF or session cookie logic broken, wrong +defaults). JS, htmx, and visual issues are deliberately out of +scope. +""" +import re +import time +import infamy +import requests + + +with infamy.Test() as test: + with test.step("Set up topology and wait for WebUI to come up"): + env = infamy.Env() + target = env.attach("target", "mgmt") + password = env.get_password("target") + + # Bracket the host so IPv6 link-local mgmt addresses + # (e.g. fe80::...%eth0) form a valid URL. + base = f"https://[{target.location.host}]" + + sess = requests.Session() + sess.verify = False # device ships a self-signed cert + + # env.attach() returns once NETCONF/RESTCONF is reachable, but + # nginx + the Go webui process may still be coming up. Poll + # /login briefly to ride out the early-boot window where + # default.conf's error_page would otherwise hand us /50x.html. + for _ in range(15): + try: + r = sess.get(f"{base}/login", timeout=10) + if r.status_code == 200 and 'name="csrf"' in r.text: + break + except requests.RequestException: + pass + time.sleep(1) + else: + test.fail("WebUI did not come up within 15s") + m = re.search(r'name="csrf"\s+value="([^"]+)"', r.text) + if not m: + test.fail("CSRF token field not found on /login") + login_csrf = m.group(1) + + with test.step("Authenticate as admin and load the dashboard"): + r = sess.post( + f"{base}/login", + data={"username": "admin", "password": password, "csrf": login_csrf}, + allow_redirects=False, + timeout=10, + ) + if r.status_code != 303: + test.fail(f"POST /login: expected 303, got {r.status_code}") + if "session" not in sess.cookies: + test.fail("session cookie not set after successful login") + + r = sess.get(f"{base}/", timeout=10) + if r.status_code != 200: + test.fail(f"GET /: expected 200, got {r.status_code}") + # base.html ships an htmx-config meta tag on every authenticated page. + if 'name="htmx-config"' not in r.text: + test.fail("htmx-config meta tag missing from /") + m = re.search(r'name="csrf-token"\s+content="([^"]+)"', r.text) + if not m: + test.fail("csrf-token meta tag not found on dashboard") + meta_csrf = m.group(1) + + with test.step("Logout clears the session cookie"): + r = sess.post( + f"{base}/logout", + data={"csrf": meta_csrf}, + allow_redirects=False, + timeout=10, + ) + if r.status_code != 303: + test.fail(f"POST /logout: expected 303, got {r.status_code}") + if "session" in sess.cookies: + test.fail("session cookie still present after logout") + + test.succeed()