test: Add test for login flow in WebUI

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 <lazzer@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 21:02:16 +02:00
parent 7e19ee8777
commit f5c549f669
8 changed files with 363 additions and 0 deletions
+3
View File
@@ -7,3 +7,6 @@
- name: SSH
suite: ssh/all.yaml
- name: Web UI
suite: webui/all.yaml
+3
View File
@@ -0,0 +1,3 @@
---
- name: Web UI Login and CSRF Protection
case: login_csrf/test.py
+1
View File
@@ -0,0 +1 @@
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
+170
View File
@@ -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()
@@ -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> mgmt }",
pos="0,12!",
requires="controller",
];
target [
label="{ <mgmt> mgmt } | target",
pos="10,12!",
requires="infix",
];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x1 Pages: 1 -->
<svg width="424pt" height="45pt"
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
<title>1x1</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+86
View File
@@ -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()