From cd02f560d6135b5f8c6ab2e0378320e95d68b732 Mon Sep 17 00:00:00 2001 From: ball42 Date: Wed, 8 Jul 2026 13:32:01 -0500 Subject: [PATCH 1/6] feat: add session-timeout ladder and fail-safe resolver (J6) --- app.py | 13 +++++++++++++ tests/test_session.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/test_session.py diff --git a/app.py b/app.py index 88b528c..6b79105 100644 --- a/app.py +++ b/app.py @@ -54,6 +54,19 @@ logthis = logger.setup_child_logger("jawa", "app") error_message = "" +SESSION_TIMEOUT_CHOICES = (15, 60, 240, 480) +DEFAULT_SESSION_TIMEOUT = 15 + + +def _resolve_session_timeout(config: dict) -> int: + """Resolve the configured session timeout (minutes) against the + allowed ladder. Any missing / malformed / off-ladder value fails + safe to the 15-minute default (never longer).""" + value = config.get("session_timeout_minutes") + if value in SESSION_TIMEOUT_CHOICES: + return value + return DEFAULT_SESSION_TIMEOUT + # Initiate Flask app = Flask(__name__) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..73ebd04 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,22 @@ +"""Session timeout resolution, enforcement, and cookie hardening (J6/B4).""" + +import app as jawa_app + + +def test_resolve_defaults_to_15_when_missing(): + assert jawa_app._resolve_session_timeout({}) == 15 + + +def test_resolve_accepts_ladder_values(): + for v in (15, 60, 240, 480): + assert jawa_app._resolve_session_timeout( + {"session_timeout_minutes": v} + ) == v + + +def test_resolve_rejects_off_ladder_values(): + # Off-ladder, wrong type, and absurd values all fail safe to 15. + for bad in (0, 5, 999999, -10, "60", None, 61): + assert jawa_app._resolve_session_timeout( + {"session_timeout_minutes": bad} + ) == 15 From e9ddec48ecd899990bf8acabda8220f65fe75ff8 Mon Sep 17 00:00:00 2001 From: ball42 Date: Wed, 8 Jul 2026 15:00:04 -0500 Subject: [PATCH 2/6] fix: enforce session lifetime and harden session cookies (B4) --- app.py | 17 ++++++++++++++--- tests/test_session.py | 36 +++++++++++++++++++++++++++++++++++- views/home_view.py | 1 + 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 6b79105..ecad516 100644 --- a/app.py +++ b/app.py @@ -63,17 +63,28 @@ def _resolve_session_timeout(config: dict) -> int: allowed ladder. Any missing / malformed / off-ladder value fails safe to the 15-minute default (never longer).""" value = config.get("session_timeout_minutes") - if value in SESSION_TIMEOUT_CHOICES: + if type(value) is int and value in SESSION_TIMEOUT_CHOICES: return value return DEFAULT_SESSION_TIMEOUT # Initiate Flask app = Flask(__name__) +app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", +) -# Session heartbeat +# Session heartbeat: slide the window and apply the admin-configured +# timeout (fail-safe to 15 min) on every request, so a /setup change +# takes effect immediately with no restart. @app.before_request -def func() -> None: +def _session_heartbeat() -> None: + from bin import data_store + + minutes = _resolve_session_timeout(data_store.get_server_config()) + app.permanent_session_lifetime = timedelta(minutes=minutes) session.modified = True diff --git a/tests/test_session.py b/tests/test_session.py index 73ebd04..c90bb60 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -16,7 +16,41 @@ def test_resolve_accepts_ladder_values(): def test_resolve_rejects_off_ladder_values(): # Off-ladder, wrong type, and absurd values all fail safe to 15. - for bad in (0, 5, 999999, -10, "60", None, 61): + # Bools and ladder-equal floats must also be rejected (strict int). + for bad in (0, 5, 999999, -10, "60", None, 61, True, False, 60.0): assert jawa_app._resolve_session_timeout( {"session_timeout_minutes": bad} ) == 15 + + +def test_cookie_flags_are_hardened(): + assert jawa_app.app.config["SESSION_COOKIE_SECURE"] is True + assert jawa_app.app.config["SESSION_COOKIE_HTTPONLY"] is True + assert jawa_app.app.config["SESSION_COOKIE_SAMESITE"] == "Lax" + + +def test_login_makes_session_permanent(logged_in_client): + with logged_in_client.session_transaction() as sess: + assert sess.permanent is True + + +def test_before_request_applies_configured_timeout(logged_in_client, jawa_env): + import json + # Configure an extended (4h) timeout. + data = json.loads(jawa_env.server_file.read_text()) + data["session_timeout_minutes"] = 240 + jawa_env.server_file.write_text(json.dumps(data)) + # Any authed request runs before_request, which sets the lifetime. + logged_in_client.get("/dashboard") + from datetime import timedelta + assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=240) + + +def test_before_request_failsafe_on_bad_config(logged_in_client, jawa_env): + import json + data = json.loads(jawa_env.server_file.read_text()) + data["session_timeout_minutes"] = 999999 + jawa_env.server_file.write_text(json.dumps(data)) + logged_in_client.get("/dashboard") + from datetime import timedelta + assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=15) diff --git a/views/home_view.py b/views/home_view.py index e56e3c2..3af0eca 100644 --- a/views/home_view.py +++ b/views/home_view.py @@ -174,6 +174,7 @@ def login() -> Response: + str(escape(session["username"])) ) + session.permanent = True return redirect(url_for(DASHBOARD_ENDPOINT)) if "username" not in session: From 4e17ca42cc8ff798f8fb79680b5d00ee636b0889 Mon Sep 17 00:00:00 2001 From: ball42 Date: Wed, 8 Jul 2026 15:19:26 -0500 Subject: [PATCH 3/6] feat: persist admin-configured session timeout at /setup (J6) --- app.py | 13 +++++++++++++ tests/test_session.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app.py b/app.py index ecad516..2dae99a 100644 --- a/app.py +++ b/app.py @@ -276,6 +276,15 @@ def setup() -> Union[Response, str]: jps_url = request.form.get("jss-lock") jps2_check = request.form.get("alternate-jamf") jps_url2 = request.form.get("alternate") + timeout_raw = request.form.get("session_timeout_minutes", "") + try: + timeout_val = int(timeout_raw) + except (TypeError, ValueError): + timeout_val = DEFAULT_SESSION_TIMEOUT + # Clamp to the allowed ladder; never store an off-ladder value. + session_timeout = _resolve_session_timeout( + {"session_timeout_minutes": timeout_val} + ) logthis.info( f"{session.get('username')} made JAWA Setup Changes\n" f"JAWA URL: {server_url}\n" @@ -294,6 +303,7 @@ def setup() -> Union[Response, str]: "jawa_address": server_url, "jps_url": jps_url, "alternate_jps": jps_url2, + "session_timeout_minutes": session_timeout, } json.dump(server_json, outfile) elif os.path.isfile(server_json_file): @@ -302,6 +312,7 @@ def setup() -> Union[Response, str]: "jawa_address": server_url, "jps_url": jps_url, "alternate_jps": jps_url2, + "session_timeout_minutes": session_timeout, } json.dump(server_json, outfile) with open(server_json_file, "r") as fin: @@ -330,6 +341,7 @@ def setup() -> Union[Response, str]: json.dump(server_json, outfile) with open(server_json_file, "r") as fin: server_json = json.load(fin) + session_timeout = _resolve_session_timeout(server_json) jps_url2 = server_json.get("alternate_jps") if jps_url2 == str(escape(session["url"])): primary_jps = server_json["jps_url"] @@ -342,6 +354,7 @@ def setup() -> Union[Response, str]: jps_url=primary_jps, jps_url2=jps_url2, jawa_url=jawa_url, + session_timeout=session_timeout, username=session.get("username"), ) diff --git a/tests/test_session.py b/tests/test_session.py index c90bb60..5aea81e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -54,3 +54,34 @@ def test_before_request_failsafe_on_bad_config(logged_in_client, jawa_env): logged_in_client.get("/dashboard") from datetime import timedelta assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=15) + + +def test_setup_persists_valid_timeout(logged_in_client, jawa_env): + import json + logged_in_client.post( + "/setup", + data={ + "address": "https://jawa.example.test", + "jss-lock": "https://jamf.example.test", + "alternate": "", + "session_timeout_minutes": "240", + }, + ) + data = json.loads(jawa_env.server_file.read_text()) + assert data["session_timeout_minutes"] == 240 + + +def test_setup_rejects_off_ladder_timeout(logged_in_client, jawa_env): + import json + logged_in_client.post( + "/setup", + data={ + "address": "https://jawa.example.test", + "jss-lock": "https://jamf.example.test", + "alternate": "", + "session_timeout_minutes": "999999", + }, + ) + data = json.loads(jawa_env.server_file.read_text()) + # Off-ladder input is clamped to the safe default, never stored raw. + assert data["session_timeout_minutes"] == 15 From 9e01cee65a79f9b418dc67d406faf9ee6574f1f5 Mon Sep 17 00:00:00 2001 From: ball42 Date: Wed, 8 Jul 2026 16:00:57 -0500 Subject: [PATCH 4/6] feat: add session-timeout dropdown with extended-tier warnings to setup (J6) --- templates/setup/setup.html | 16 ++++++++++++++++ tests/test_session.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/templates/setup/setup.html b/templates/setup/setup.html index a412ca8..e131cde 100644 --- a/templates/setup/setup.html +++ b/templates/setup/setup.html @@ -75,6 +75,22 @@

Server Setup

title="Use https://" value="{{ jps_url2 if jps_url2 }}"> +
+ + + + Longer sessions are convenient for workflow testing but increase risk: + a signed-in console left unattended could be misused on-site. Extended + tiers (4h/8h) are a deliberate trade-off; the 15-minute default is the + most secure. + +
+