mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -7,3 +7,6 @@
|
||||
|
||||
- name: SSH
|
||||
suite: ssh/all.yaml
|
||||
|
||||
- name: Web UI
|
||||
suite: webui/all.yaml
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
- name: Web UI Login and CSRF Protection
|
||||
case: login_csrf/test.py
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+170
@@ -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--target -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>host:mgmt--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 |
@@ -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()
|
||||
Reference in New Issue
Block a user