129 lines
5.5 KiB
Python
129 lines
5.5 KiB
Python
"""Verify the cloud IP HTTPS deployment without printing credentials or records.
|
|
|
|
Run on the server: python3 scripts/verify_cloud_https.py
|
|
Uses the system CA store and verifies the server certificate, including its IP.
|
|
Only login/logout and read-only aggregate queries are submitted.
|
|
"""
|
|
import hashlib
|
|
import http.cookiejar
|
|
import json
|
|
from pathlib import Path
|
|
import ssl
|
|
import sys
|
|
from urllib import error, request
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BASE = "https://203.195.219.128"
|
|
API = "/wolin/api/studentsManagement/workspace"
|
|
MODULES = ("students", "classes", "teachers", "advisors", "scores", "employment")
|
|
|
|
|
|
class CheckFailed(Exception):
|
|
pass
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise CheckFailed(message)
|
|
|
|
|
|
class NoRedirect(request.HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
def main():
|
|
cookies = http.cookiejar.CookieJar()
|
|
client = request.build_opener(
|
|
request.ProxyHandler({}), NoRedirect(),
|
|
request.HTTPSHandler(context=ssl.create_default_context()),
|
|
request.HTTPCookieProcessor(cookies),
|
|
)
|
|
|
|
def check(path, status=200, data=None, headers=None):
|
|
headers = dict(headers or {})
|
|
if data is not None:
|
|
headers.update({"Content-Type": "application/json", "Origin": BASE})
|
|
req = request.Request(BASE + path, headers=headers,
|
|
data=None if data is None else json.dumps(data).encode())
|
|
try:
|
|
response = client.open(req, timeout=30)
|
|
except error.HTTPError as exc:
|
|
response = exc
|
|
except error.URLError as exc:
|
|
message = ("TLS certificate verification failed" if isinstance(
|
|
exc.reason, ssl.SSLCertVerificationError) else "HTTPS connection failed")
|
|
raise CheckFailed(message) from None
|
|
with response:
|
|
body = response.read()
|
|
require(response.status == status,
|
|
f"{path}: expected HTTP {status}, received {response.status}")
|
|
require("WWW-Authenticate" not in response.headers,
|
|
f"{path}: unexpected native authentication challenge")
|
|
return body, response.headers
|
|
|
|
portal, _ = check("/")
|
|
require(b'href="/blackjack/"' in portal and b'href="/wolin/"' in portal,
|
|
"Portal entry links missing")
|
|
game, _ = check("/blackjack/")
|
|
require(hashlib.sha256(game).digest() == hashlib.sha256(
|
|
(ROOT / "deploy/site/blackjack/index.html").read_bytes()).digest(),
|
|
"Served game differs from preserved build")
|
|
_, headers = check("/wolin/", 303, headers={"Accept": "text/html"})
|
|
require(headers.get("Location") == "/wolin/login", "Login redirect incorrect")
|
|
login, _ = check("/wolin/login")
|
|
require(b'id="login-form"' in login, "Login form missing")
|
|
for path in (API + "/overview", "/wolin/docs", "/wolin/openapi.json"):
|
|
check(path, 401)
|
|
check(API + "/query", 401, {"module": "students", "aggregate": "count"})
|
|
|
|
# Read credentials only after successful certificate and public-route checks.
|
|
try:
|
|
credentials = dict(line.split(": ", 1) for line in
|
|
(ROOT / "artifacts/cloud-access.txt").read_text(
|
|
encoding="utf-8").splitlines() if ": " in line)
|
|
payload = {"username": credentials["Username"], "password": credentials["Password"]}
|
|
except (OSError, ValueError, KeyError):
|
|
raise CheckFailed("Administrator credentials file missing or invalid") from None
|
|
body, _ = check("/wolin/auth/login", data=payload)
|
|
require(json.loads(body).get("success") is True, "Login did not succeed")
|
|
try:
|
|
session = next((cookie for cookie in cookies if cookie.name == "wolin_session"), None)
|
|
require(session is not None, "Session cookie missing")
|
|
require(session.secure and session.has_nonstandard_attr("HttpOnly")
|
|
and session.path == "/wolin/"
|
|
and str(session.get_nonstandard_attr("SameSite")).lower() == "strict",
|
|
"Session cookie must have Secure, HttpOnly, SameSite=Strict and /wolin/ path")
|
|
_, headers = check("/wolin/")
|
|
require(headers.get("Cache-Control") == "no-store", "Private page cache policy incorrect")
|
|
body, _ = check(API + "/overview")
|
|
overview = json.loads(body)["counts"]
|
|
counts = {}
|
|
for module in MODULES:
|
|
body, _ = check(API + "/query", data={"module": module, "aggregate": "count"})
|
|
count = json.loads(body)["items"][0]["value"]
|
|
require(type(count) is int and count >= 0 and count == overview[module],
|
|
f"{module}: aggregate and overview counts differ")
|
|
counts[module] = count
|
|
finally:
|
|
check("/wolin/auth/logout", data={})
|
|
require(not list(cookies), "Logout did not clear session cookie")
|
|
check(API + "/overview", 401)
|
|
return {"status": "ok", "tls_verification": "ok", "portal": "ok",
|
|
"preserved_game": "ok", "secure_login_cookie": "ok",
|
|
"unauthenticated_access": "401", "six_modules": "ok",
|
|
"logout_access": "401", "counts": counts}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
report = main()
|
|
except CheckFailed as exc:
|
|
print(json.dumps({"status": "failed", "check": str(exc)}))
|
|
sys.exit(1)
|
|
except Exception as exc:
|
|
# Suppress response bodies, credential contents and tracebacks.
|
|
print(json.dumps({"status": "failed", "error_type": type(exc).__name__}))
|
|
sys.exit(1)
|
|
print(json.dumps(report))
|