From 608524574d56bba5440fdeb5c470c27dfc9332c2 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Mon, 21 Sep 2026 20:54:56 -0300 Subject: [PATCH] feat(auth): elevar politica de senha para 8-20 caracteres e suporte a cookies seguros em https --- src/litellm_rtksync/auth.py | 16 ++- src/litellm_rtksync/locales/en.json | 5 +- src/litellm_rtksync/locales/es.json | 5 +- src/litellm_rtksync/locales/pt.json | 5 +- src/litellm_rtksync/render.py | 4 +- src/litellm_rtksync/sessao.py | 27 ++-- src/litellm_rtksync/web.py | 51 ++++++-- tests/test_password_policy.py | 185 ++++++++++++++++++++++++++++ tests/test_sessao.py | 20 +++ tests/test_web.py | 31 +++++ 10 files changed, 315 insertions(+), 34 deletions(-) create mode 100644 tests/test_password_policy.py diff --git a/src/litellm_rtksync/auth.py b/src/litellm_rtksync/auth.py index 42c0d51..c98bf8a 100644 --- a/src/litellm_rtksync/auth.py +++ b/src/litellm_rtksync/auth.py @@ -32,7 +32,8 @@ # Politica de senha do painel. Exigida sempre que a senha for definida ou # trocada pela tela; o ambiente headless nao passa por aqui porque quem opera # DASHBOARD_PASSWORD ja controla o segredo por fora. -MIN_PASSWORD_LENGTH = 6 +MIN_PASSWORD_LENGTH = 8 +MAX_PASSWORD_LENGTH = 20 SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.<>?/\\|`~\"'" @@ -43,15 +44,18 @@ def validate_password_strength(password: str) -> list: evita o vaivem de corrigir um requisito por tentativa. """ problems = [] - if len(password or "") < MIN_PASSWORD_LENGTH: + pwd = password or "" + if len(pwd) < MIN_PASSWORD_LENGTH: problems.append("password.too_short") - if not any(c.isupper() for c in password or ""): + elif len(pwd) > MAX_PASSWORD_LENGTH: + problems.append("password.too_long") + if not any(c.isupper() for c in pwd): problems.append("password.needs_upper") - if not any(c.islower() for c in password or ""): + if not any(c.islower() for c in pwd): problems.append("password.needs_lower") - if not any(c.isdigit() for c in password or ""): + if not any(c.isdigit() for c in pwd): problems.append("password.needs_digit") - if not any(c in SPECIAL_CHARACTERS for c in password or ""): + if not any(c in SPECIAL_CHARACTERS for c in pwd): problems.append("password.needs_special") return problems diff --git a/src/litellm_rtksync/locales/en.json b/src/litellm_rtksync/locales/en.json index dcc1ffa..316ca3c 100644 --- a/src/litellm_rtksync/locales/en.json +++ b/src/litellm_rtksync/locales/en.json @@ -146,8 +146,9 @@ "password.needs_lower": "Password must contain a lowercase letter.", "password.needs_special": "Password must contain a special character.", "password.needs_upper": "Password must contain an uppercase letter.", - "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.", - "password.too_short": "Password must have at least 6 characters.", + "password.policy": "8 to 20 characters, with uppercase, lowercase, a number and a special character.", + "password.too_long": "Password must have at most 20 characters.", + "password.too_short": "Password must have at least 8 characters.", "reason.api_key": "Static key: never expires, nothing to renew", "reason.expired": "Token expired: renewal will be attempted on the next sweep", "reason.inside_margin": "Within the {margin} min margin: will be renewed on the next sweep", diff --git a/src/litellm_rtksync/locales/es.json b/src/litellm_rtksync/locales/es.json index 6b7be5a..9d75171 100644 --- a/src/litellm_rtksync/locales/es.json +++ b/src/litellm_rtksync/locales/es.json @@ -146,8 +146,9 @@ "password.needs_lower": "La contraseña necesita una letra minúscula.", "password.needs_special": "La contraseña necesita un carácter especial.", "password.needs_upper": "La contraseña necesita una letra mayúscula.", - "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.", - "password.too_short": "La contraseña necesita al menos 6 caracteres.", + "password.policy": "De 8 a 20 caracteres, con mayúscula, minúscula, número y carácter especial.", + "password.too_long": "La contraseña debe tener como máximo 20 caracteres.", + "password.too_short": "La contraseña necesita al menos 8 caracteres.", "reason.api_key": "Clave estática: no expira, nada que renovar", "reason.expired": "Token expirado: se intentará renovar en el próximo barrido", "reason.inside_margin": "Dentro del margen de {margin} min: se renovará en el próximo barrido", diff --git a/src/litellm_rtksync/locales/pt.json b/src/litellm_rtksync/locales/pt.json index ae899b1..330000b 100644 --- a/src/litellm_rtksync/locales/pt.json +++ b/src/litellm_rtksync/locales/pt.json @@ -146,8 +146,9 @@ "password.needs_lower": "A senha precisa conter uma letra minúscula.", "password.needs_special": "A senha precisa conter um caractere especial.", "password.needs_upper": "A senha precisa conter uma letra maiúscula.", - "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.", - "password.too_short": "A senha precisa ter ao menos 6 caracteres.", + "password.policy": "De 8 a 20 caracteres, com maiúscula, minúscula, número e caractere especial.", + "password.too_long": "A senha deve ter no máximo 20 caracteres.", + "password.too_short": "A senha precisa ter ao menos 8 caracteres.", "reason.api_key": "Chave estática: não expira, nada a renovar", "reason.expired": "Token expirado: renovação será tentada na próxima varredura", "reason.inside_margin": "Dentro da margem de {margin} min: será renovada na próxima varredura", diff --git a/src/litellm_rtksync/render.py b/src/litellm_rtksync/render.py index 4c2b209..fd824a2 100644 --- a/src/litellm_rtksync/render.py +++ b/src/litellm_rtksync/render.py @@ -656,8 +656,8 @@ def render_credentials_modal(auth_from_env: bool, lang: str) -> str:
{esc(translate("password.policy", lang))}
diff --git a/src/litellm_rtksync/sessao.py b/src/litellm_rtksync/sessao.py index c58f6a5..eb77a37 100644 --- a/src/litellm_rtksync/sessao.py +++ b/src/litellm_rtksync/sessao.py @@ -100,22 +100,24 @@ def usuario_da_sessao(valor: str, agora: Optional[float] = None) -> Optional[str return usuario or None -def cabecalho_para_gravar(valor: str) -> str: +def cabecalho_para_gravar(valor: str, seguro: bool = False) -> str: """Cookie de sessão: inacessível ao script da página e presa a este site. - Sem `Secure` de propósito: o painel é servido em HTTP no loopback, e um - cookie `Secure` simplesmente não seria gravado ali. + Sem `Secure` por padrão para desenvolvimento em loopback HTTP; com + `seguro=True`, adiciona `; Secure` quando servido em HTTPS ou atrás de proxy. """ + s = "; Secure" if seguro else "" return ( f"{NOME_DO_COOKIE}={valor}; Path=/; HttpOnly; SameSite=Strict; " - f"Max-Age={VALIDADE_EM_SEGUNDOS}" + f"Max-Age={VALIDADE_EM_SEGUNDOS}{s}" ) -def cabecalho_para_apagar() -> str: +def cabecalho_para_apagar(seguro: bool = False) -> str: + s = "; Secure" if seguro else "" return ( f"{NOME_DO_COOKIE}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " - f"Max-Age=0; HttpOnly; SameSite=Strict" + f"Max-Age=0; HttpOnly; SameSite=Strict{s}" ) @@ -181,26 +183,27 @@ def ler_estado_sso(valor: str, agora: Optional[float] = None) -> Optional[dict]: return {"state": state, "nonce": nonce, "verificador": verificador} -def cabecalho_para_gravar_estado(valor: str) -> str: +def cabecalho_para_gravar_estado(valor: str, seguro: bool = False) -> str: """Cookie de estado do SSO: `Lax`, curto e restrito ao caminho do fluxo. NÃO pode ser `SameSite=Strict` como o de sessão: a volta do provedor é uma navegação vinda de outro site, e um cookie `Strict` simplesmente não é enviado nela — a falha apareceria como "login que não funciona", sem erro - nenhum na tela. Sem `Secure` pelo mesmo motivo do cookie de sessão: o painel - é servido em HTTP no loopback. + nenhum na tela. Sem `Secure` no loopback, mas com `; Secure` quando em HTTPS. """ + s = "; Secure" if seguro else "" return ( f"{NOME_DO_COOKIE_DE_ESTADO}={valor}; Path=/sso/; HttpOnly; SameSite=Lax; " - f"Max-Age={VALIDADE_DO_ESTADO_EM_SEGUNDOS}" + f"Max-Age={VALIDADE_DO_ESTADO_EM_SEGUNDOS}{s}" ) -def cabecalho_para_apagar_estado() -> str: +def cabecalho_para_apagar_estado(seguro: bool = False) -> str: """Consumo de uso único: o mesmo `Path` do cookie, ou o navegador não o apaga.""" + s = "; Secure" if seguro else "" return ( f"{NOME_DO_COOKIE_DE_ESTADO}=; Path=/sso/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " - f"Max-Age=0; HttpOnly; SameSite=Lax" + f"Max-Age=0; HttpOnly; SameSite=Lax{s}" ) diff --git a/src/litellm_rtksync/web.py b/src/litellm_rtksync/web.py index 63aad04..09c1fe6 100644 --- a/src/litellm_rtksync/web.py +++ b/src/litellm_rtksync/web.py @@ -574,6 +574,31 @@ def serve_login_page(self, erro: str = "", mensagem: str = "") -> None: """Formulário de entrada: a porta do navegador para o painel.""" self.respond_html(self.pagina_de_login(self.resolve_language(), erro, mensagem=mensagem)) + def eh_conexao_segura(self) -> bool: + """Determina se a requisição veio por canal seguro (HTTPS). + + Detecta via proxy reverso (cabeçalhos X-Forwarded-Proto, X-Forwarded-Scheme, + X-Forwarded-Ssl, Front-End-Https) ou quando o SSO foi configurado com uma + base_url https://. + """ + proto = (self.headers.get("X-Forwarded-Proto") or "").lower().strip() + if proto == "https": + return True + scheme = (self.headers.get("X-Forwarded-Scheme") or "").lower().strip() + if scheme == "https": + return True + if (self.headers.get("X-Forwarded-Ssl") or "").lower().strip() == "on": + return True + if (self.headers.get("Front-End-Https") or "").lower().strip() == "on": + return True + try: + cfg = self.configuracao_sso() + if cfg and str(cfg.get("base_url") or "").lower().strip().startswith("https://"): + return True + except Exception: + pass + return False + def handle_login(self) -> None: """Valida a credencial do formulário e emite o cookie de sessão.""" endereco = protecao.endereco_do_cliente(self.client_address) @@ -622,7 +647,10 @@ def handle_login(self) -> None: protecao.limpa_apos_sucesso(endereco) self.send_response(HTTPStatus.FOUND) self.send_header("Location", "/") - self.send_header("Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir(usuario))) + seguro = self.eh_conexao_segura() + self.send_header( + "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir(usuario), seguro=seguro) + ) self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", "0") self.end_headers() @@ -632,8 +660,9 @@ def handle_logout(self) -> None: self.authenticated_user = "" self.send_response(HTTPStatus.FOUND) self.send_header("Location", "/login?logout=1") - self.send_header("Set-Cookie", sessao.cabecalho_para_apagar()) - self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado()) + seguro = self.eh_conexao_segura() + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar(seguro=seguro)) + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro)) self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") self.send_header("Pragma", "no-cache") self.send_header("Content-Length", "0") @@ -678,7 +707,8 @@ def recusa_sso(self) -> None: self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(corpo))) - self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado()) + seguro = self.eh_conexao_segura() + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro)) self.send_header("Cache-Control", "no-store") self.end_headers() self.write_body(corpo) @@ -701,11 +731,12 @@ def pousa_sessao_federada(self, email: str) -> None: self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(corpo))) + seguro = self.eh_conexao_segura() self.send_header( - "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir("sso:" + email)) + "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir("sso:" + email), seguro=seguro) ) # O cookie de ida já cumpriu o papel: uso único. - self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado()) + self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro)) self.send_header("Cache-Control", "no-store") self.end_headers() self.write_body(corpo) @@ -791,10 +822,12 @@ def inicia_oidc(self) -> None: self.send_header( "Location", sso.url_de_autorizacao(documento, cfg_obj, state, nonce, verificador) ) + seguro = self.eh_conexao_segura() self.send_header( "Set-Cookie", sessao.cabecalho_para_gravar_estado( - sessao.emitir_estado_sso(state, nonce, verificador) + sessao.emitir_estado_sso(state, nonce, verificador), + seguro=seguro, ), ) self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") @@ -1189,9 +1222,11 @@ def handle_language(self, campos: Dict[str, List[str]]) -> None: self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", destino) + seguro = self.eh_conexao_segura() + s = "; Secure" if seguro else "" self.send_header( "Set-Cookie", - f"rtksync_lang={escolhido}; Path=/; Max-Age=31536000; SameSite=Lax", + f"rtksync_lang={escolhido}; Path=/; Max-Age=31536000; SameSite=Lax{s}", ) self.send_header("Content-Length", "0") self.end_headers() diff --git a/tests/test_password_policy.py b/tests/test_password_policy.py new file mode 100644 index 0000000..0eab3e6 --- /dev/null +++ b/tests/test_password_policy.py @@ -0,0 +1,185 @@ +"""Testes da politica de senha e da credencial gravada em SQLite.""" + +import os +import tempfile +import unittest + +from litellm_rtksync.auth import ( + hash_password, + password_matches, + read_db_credentials, + validate_password_strength, + write_db_credentials, +) +from litellm_rtksync.config import Settings +from litellm_rtksync.prefs import resolve_prefs_path + + +class TestPasswordStrength(unittest.TestCase): + def test_accepts_a_password_meeting_every_rule(self): + self.assertEqual(validate_password_strength("Sample1!"), []) + + def test_reports_every_broken_rule_at_once(self): + """Uma regra por tentativa faria o usuario adivinhar a politica aos poucos.""" + problems = validate_password_strength("abc") + self.assertIn("password.too_short", problems) + self.assertIn("password.needs_upper", problems) + self.assertIn("password.needs_digit", problems) + self.assertIn("password.needs_special", problems) + + def test_eight_characters_is_the_floor(self): + self.assertIn("password.too_short", validate_password_strength("Ab1!cde")) + self.assertEqual(validate_password_strength("Ab1!cdef"), []) + + def test_twenty_characters_is_the_ceiling(self): + self.assertEqual(validate_password_strength("Ab1!" + "x" * 16), []) + self.assertIn("password.too_long", validate_password_strength("Ab1!" + "x" * 17)) + + def test_each_class_is_required(self): + self.assertEqual(validate_password_strength("ABC123!@"), ["password.needs_lower"]) + self.assertEqual(validate_password_strength("abc123!@"), ["password.needs_upper"]) + self.assertEqual(validate_password_strength("Abcdef!@"), ["password.needs_digit"]) + self.assertEqual(validate_password_strength("Abcdef12"), ["password.needs_special"]) + + def test_the_factory_password_would_be_refused_today(self): + self.assertTrue(validate_password_strength("pathbit")) + + +class TestPasswordHashing(unittest.TestCase): + def test_hash_is_salted_so_two_hashes_never_match(self): + self.assertNotEqual(hash_password("Sample1!"), hash_password("Sample1!")) + + def test_the_password_never_appears_in_the_stored_value(self): + self.assertNotIn("Sample1!", hash_password("Sample1!")) + + def test_matching_and_non_matching(self): + stored = hash_password("Sample1!") + self.assertTrue(password_matches(stored, "Sample1!")) + self.assertFalse(password_matches(stored, "Pathbit1")) + self.assertFalse(password_matches(stored, "")) + + def test_legacy_plaintext_still_authenticates(self): + """Quem ja tinha senha no arquivo antigo nao pode ficar trancado do lado de fora.""" + self.assertTrue(password_matches("senha-antiga", "senha-antiga")) + self.assertFalse(password_matches("senha-antiga", "outra")) + + def test_corrupted_stored_value_denies_access(self): + self.assertFalse(password_matches("pbkdf2_sha256$quebrado", "qualquer")) + + +class TestCredentialsInSqlite(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.settings = Settings(data_dir=self.tmp.name) + + def test_no_password_stored_means_the_banner_shows(self): + self.assertTrue(self.settings.is_default_password()) + self.assertFalse(self.settings.has_stored_password()) + + def test_the_banner_disappears_once_a_password_is_stored(self): + self.assertTrue(self.settings.update_auth_credentials("admin", "Sample1!")) + self.assertTrue(self.settings.has_stored_password()) + self.assertFalse(self.settings.is_default_password()) + + def test_a_weak_password_is_refused_and_changes_nothing(self): + self.assertFalse(self.settings.update_auth_credentials("admin", "fraca")) + self.assertFalse(self.settings.has_stored_password()) + self.assertTrue(self.settings.is_default_password()) + + def test_the_stored_password_authenticates_and_the_old_one_stops(self): + self.settings.update_auth_credentials("admin", "Sample1!") + self.assertTrue(self.settings.verify_credentials("admin", "Sample1!")) + self.assertFalse(self.settings.verify_credentials("admin", "pathbit")) + + def test_the_password_is_never_written_in_clear_text(self): + self.settings.update_auth_credentials("admin", "Sample1!") + prefs = resolve_prefs_path(self.tmp.name) + with open(prefs, "rb") as handle: + raw = handle.read() + self.assertNotIn(b"Sample1!", raw) + + def test_headless_mode_refuses_to_write(self): + """Com a senha vindo do ambiente, gravar aqui criaria estado que ninguem le.""" + headless = Settings(data_dir=self.tmp.name, dashboard_auth_from_env=True) + self.assertFalse(headless.update_auth_credentials("admin", "Sample1!")) + # E o banner nao aparece: quem opera o ambiente ja controla o segredo. + self.assertFalse(headless.is_default_password()) + + def test_credentials_round_trip_through_the_database(self): + prefs = resolve_prefs_path(self.tmp.name) + self.assertIsNone(read_db_credentials(prefs)) + self.assertTrue(write_db_credentials(prefs, "operador", "Sample1!")) + user, stored = read_db_credentials(prefs) + self.assertEqual(user, "operador") + self.assertTrue(password_matches(stored, "Sample1!")) + + +class TestNoFactoryPassword(unittest.TestCase): + """Uma senha padrao estatica e, por definicao, uma credencial publica.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def test_the_settings_default_carries_no_password(self): + self.assertEqual(Settings(data_dir=self.tmp.name).dashboard_password, "") + + def test_no_guessable_password_opens_the_panel(self): + s = Settings(data_dir=self.tmp.name) + s.ensure_recovery_hash() + for guess in ("pathbit", "admin", "", "password", "123456", "litellmrtksync"): + self.assertFalse(s.verify_credentials("admin", guess), guess) + + def test_the_recovery_credential_is_the_only_way_in_before_a_password_is_set(self): + s = Settings(data_dir=self.tmp.name) + recovery, _ = s.ensure_recovery_hash() + self.assertTrue(s.verify_credentials("admin", recovery)) + + def test_the_recovery_credential_is_long_enough_to_resist_guessing(self): + s = Settings(data_dir=self.tmp.name) + recovery, _ = s.ensure_recovery_hash() + self.assertGreaterEqual(len(recovery), 32) + + +class TestEnvManagedAuthNeedsAPassword(unittest.TestCase): + def setUp(self): + self.original = { + chave: os.environ.get(chave) + for chave in ("DASHBOARD_USER", "DASHBOARD_PASSWORD") + } + self.addCleanup(self.restaurar) + for chave in self.original: + os.environ.pop(chave, None) + + def settings(self): + return Settings.from_env() + + def restaurar(self): + for chave, valor in self.original.items(): + if valor is None: + os.environ.pop(chave, None) + else: + os.environ[chave] = valor + + def test_only_the_user_set_is_not_env_managed(self): + os.environ["DASHBOARD_USER"] = "admin" + self.assertFalse(self.settings().dashboard_auth_from_env) + + def test_the_shape_shipped_in_the_compose_example_is_not_env_managed(self): + os.environ["DASHBOARD_USER"] = "admin" + os.environ["DASHBOARD_PASSWORD"] = "" + settings = self.settings() + self.assertFalse(settings.dashboard_auth_from_env) + self.assertEqual(settings.dashboard_user, "admin") + + def test_a_real_password_is_env_managed(self): + os.environ["DASHBOARD_PASSWORD"] = "Sample1!" + self.assertTrue(self.settings().dashboard_auth_from_env) + + def test_nothing_set_is_not_env_managed(self): + self.assertFalse(self.settings().dashboard_auth_from_env) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sessao.py b/tests/test_sessao.py index 7e1976f..01f9584 100644 --- a/tests/test_sessao.py +++ b/tests/test_sessao.py @@ -73,6 +73,26 @@ def test_sair_apaga_cookie_de_estado_com_data_no_passado(self): self.assertIn("Max-Age=0", cabecalho) self.assertIn("Expires=Thu, 01 Jan 1970 00:00:00 GMT", cabecalho) + def test_cabecalho_seguro_inclui_secure(self): + cabecalho = sessao.cabecalho_para_gravar("qualquer", seguro=True) + self.assertIn("; Secure", cabecalho) + cabecalho_inseguro = sessao.cabecalho_para_gravar("qualquer", seguro=False) + self.assertNotIn("Secure", cabecalho_inseguro) + + def test_apagar_seguro_inclui_secure(self): + cabecalho = sessao.cabecalho_para_apagar(seguro=True) + self.assertIn("; Secure", cabecalho) + cabecalho_inseguro = sessao.cabecalho_para_apagar(seguro=False) + self.assertNotIn("Secure", cabecalho_inseguro) + + def test_estado_seguro_inclui_secure(self): + cabecalho = sessao.cabecalho_para_gravar_estado("qualquer", seguro=True) + self.assertIn("; Secure", cabecalho) + cabecalho_apagar = sessao.cabecalho_para_apagar_estado(seguro=True) + self.assertIn("; Secure", cabecalho_apagar) + cabecalho_inseguro = sessao.cabecalho_para_gravar_estado("qualquer", seguro=False) + self.assertNotIn("Secure", cabecalho_inseguro) + def test_assinatura_vinculada_ao_nome_do_cookie(self): """Um cookie emitido para um produto nao pode ser aceito por outro.""" import hmac diff --git a/tests/test_web.py b/tests/test_web.py index c8009b5..f6d92aa 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -364,6 +364,37 @@ def test_the_team_column_shows_the_alias_not_the_raw_id(self): self.assertIn('title="t1"', corpo, "o id precisa continuar acessível para casar tela e API") + def test_login_cookie_with_x_forwarded_proto_includes_secure(self): + """When behind an HTTPS reverse proxy (X-Forwarded-Proto: https), cookies must be Secure.""" + import http.client + import urllib.parse + conn = http.client.HTTPConnection("127.0.0.1", PORTA, timeout=3.0) + body = urllib.parse.urlencode({"usuario": "admin", "senha": self.recuperacao}) + conn.request("POST", "/login", body=body, headers={ + "Content-Type": "application/x-www-form-urlencoded", + "X-Forwarded-Proto": "https", + }) + resp = conn.getresponse() + self.assertEqual(resp.status, 302) + cookie = resp.headers.get("Set-Cookie", "") + self.assertIn("; Secure", cookie) + conn.close() + + def test_login_cookie_without_x_forwarded_proto_no_secure(self): + """When accessed over local plain HTTP without reverse proxy, cookies omit Secure.""" + import http.client + import urllib.parse + conn = http.client.HTTPConnection("127.0.0.1", PORTA, timeout=3.0) + body = urllib.parse.urlencode({"usuario": "admin", "senha": self.recuperacao}) + conn.request("POST", "/login", body=body, headers={ + "Content-Type": "application/x-www-form-urlencoded", + }) + resp = conn.getresponse() + self.assertEqual(resp.status, 302) + cookie = resp.headers.get("Set-Cookie", "") + self.assertNotIn("Secure", cookie) + conn.close() + class TestRotuloDoTime(unittest.TestCase): """O apelido do time na tabela de chaves.