account: fix Wolf card unlock flow

This commit is contained in:
codex 2026-05-22 02:19:16 -03:00
parent b4373b9325
commit 1a231dd474
4 changed files with 61 additions and 8 deletions

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
from typing import Any from typing import Any
from flask import jsonify, request from flask import jsonify, request
@ -8,10 +9,45 @@ from .. import ariadne_client
from ..keycloak import require_auth, require_account_access from ..keycloak import require_auth, require_account_access
def _valid_ip(value: str) -> str:
return str(ipaddress.ip_address(value.strip()))
def _public_ip_from_chain(values: list[str]) -> str:
"""Return the nearest public client IP from a trusted proxy chain."""
candidates: list[str] = []
for value in values:
for item in value.split(","):
item = item.strip()
if item:
candidates.append(item)
for candidate in reversed(candidates):
try:
ip = ipaddress.ip_address(candidate)
except ValueError:
continue
if ip.is_global:
return str(ip)
for candidate in reversed(candidates):
try:
return _valid_ip(candidate)
except ValueError:
continue
return ""
def _client_ip() -> str: def _client_ip() -> str:
# ProxyFix normalizes this from the trusted Traefik hop. Reading raw # Walk right-to-left through proxy-added headers. This ignores spoofed
# forwarding headers here would let a browser request choose an unlock IP. # left-most values while still finding the public IP before cluster hops.
return request.remote_addr or "" chain = [
request.headers.get("X-Forwarded-For", ""),
request.headers.get("X-Real-IP", ""),
request.remote_addr or "",
]
return _public_ip_from_chain(chain)
def _json_payload() -> dict[str, Any]: def _json_payload() -> dict[str, Any]:

View File

@ -38,7 +38,7 @@ def test_wolf_status_proxies_source_ip(monkeypatch) -> None:
resp = client.get( resp = client.get(
"/api/account/wolf", "/api/account/wolf",
environ_base={"REMOTE_ADDR": "1.2.3.4"}, environ_base={"REMOTE_ADDR": "1.2.3.4"},
headers={"X-Forwarded-For": "9.9.9.9, 10.0.0.1"}, headers={"X-Forwarded-For": "9.9.9.9, 181.1.87.186, 10.0.0.1"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
@ -52,13 +52,27 @@ def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
"/api/account/wolf/firewall/unlock", "/api/account/wolf/firewall/unlock",
json={"ttl_seconds": 120}, json={"ttl_seconds": 120},
environ_base={"REMOTE_ADDR": "5.6.7.8"}, environ_base={"REMOTE_ADDR": "5.6.7.8"},
headers={"X-Real-IP": "9.9.9.9"}, headers={"X-Real-IP": "10.42.28.0"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)]
def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
client, ariadne = make_client(monkeypatch)
resp = client.post(
"/api/account/wolf/firewall/unlock",
json={},
environ_base={"REMOTE_ADDR": "10.42.28.0"},
headers={"X-Forwarded-For": "9.9.9.9, 181.1.87.186, 10.42.28.0"},
)
assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)]
def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
client, ariadne = make_client(monkeypatch) client, ariadne = make_client(monkeypatch)

View File

@ -93,7 +93,7 @@ export async function initAuth() {
pkceMethod: "S256", pkceMethod: "S256",
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
checkLoginIframe: true, checkLoginIframe: true,
scope: "openid profile email", scope: "openid profile email groups",
}); });
auth.authenticated = authenticated; auth.authenticated = authenticated;

View File

@ -19,7 +19,7 @@
</div> </div>
<div class="kv"> <div class="kv">
<div class="row"> <div class="row">
<span class="k mono">Moonlight</span> <span class="k mono">Moonlight host</span>
<span class="v mono">{{ wolf.moonlightHost }}</span> <span class="v mono">{{ wolf.moonlightHost }}</span>
</div> </div>
<div class="row"> <div class="row">
@ -44,8 +44,9 @@
<button class="primary" type="button" :disabled="wolf.unlocking" @click="$emit('unlock')"> <button class="primary" type="button" :disabled="wolf.unlocking" @click="$emit('unlock')">
{{ wolf.unlocking ? "Unlocking..." : "Unlock Moonlight" }} {{ wolf.unlocking ? "Unlocking..." : "Unlock Moonlight" }}
</button> </button>
<button class="copy mono" type="button" :disabled="wolf.loading" @click="$emit('refresh')">refresh</button> <button class="copy mono" type="button" :disabled="wolf.loading" @click="$emit('refresh')">Refresh</button>
</div> </div>
<div class="hint mono">Use the host above in Moonlight after unlocking your current IP.</div>
<div v-if="wolf.pendingPairRequests.length" class="secret-box"> <div v-if="wolf.pendingPairRequests.length" class="secret-box">
<div class="secret-head"> <div class="secret-head">
@ -113,3 +114,5 @@ defineProps({
defineEmits(["refresh", "unlock", "pair", "game-mode", "admin-unlock"]); defineEmits(["refresh", "unlock", "pair", "game-mode", "admin-unlock"]);
</script> </script>
<style scoped src="../styles/account.css"></style>