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:
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.