Add browser-friendly remote token bootstrap

This commit is contained in:
Dymas
2026-05-17 14:59:48 +02:00
parent c91db90c1b
commit 47480efa30
6 changed files with 130 additions and 4 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.30.4 - 2026-05-17
- Added browser-friendly remote auth bootstrap: remote users can open the app once with `?token=...`, the server stores the token in an HTTP-only cookie, and then strips the token back out of the URL with a redirect.
- Kept existing remote header auth support for API clients while extending regression coverage for cookie, query-token, and bootstrap redirect access.
## 0.30.3 - 2026-05-17 ## 0.30.3 - 2026-05-17
- Fixed container startup under `docker-compose` and custom `USER_UID`/`USER_GID` mappings by launching the web app with `python3 /app/app.py` directly instead of relying on the `./ani-cli-web` launcher file being executable inside the container. - Fixed container startup under `docker-compose` and custom `USER_UID`/`USER_GID` mappings by launching the web app with `python3 /app/app.py` directly instead of relying on the `./ani-cli-web` launcher file being executable inside the container.
+9 -1
View File
@@ -2,7 +2,7 @@
A local web UI for a system-wide `ani-cli` install. A local web UI for a system-wide `ani-cli` install.
Current version: `0.30.3` Current version: `0.30.4`
## Project Layout ## Project Layout
@@ -223,6 +223,14 @@ Non-local clients must then send either:
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
- `X-AniCli-Web-Token: <token>` - `X-AniCli-Web-Token: <token>`
For browser access from another machine, you can also open the app once with:
```text
http://YOUR-HOST:8421/?token=your-token-here
```
The server will set an HTTP-only cookie and redirect to the same page without the token in the URL, so normal browser navigation and API polling continue to work afterward.
When the app is placed behind a loopback reverse proxy, forwarded external client IP headers such as `X-Forwarded-For` are also treated as non-local, so proxied internet or LAN traffic still needs the token. When the app is placed behind a loopback reverse proxy, forwarded external client IP headers such as `X-Forwarded-For` are also treated as non-local, so proxied internet or LAN traffic still needs the token.
This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost. This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost.
+1 -1
View File
@@ -1 +1 @@
0.30.3 0.30.4
+1 -1
View File
@@ -16,7 +16,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.30.3" VERSION = "0.30.4"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" ALLANIME_REFERER = "https://allmanga.to"
+63 -1
View File
@@ -8,10 +8,11 @@ import os
import shutil import shutil
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
from http.cookies import SimpleCookie
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import ( from app_support import (
ANI_CLI, ANI_CLI,
@@ -68,6 +69,7 @@ def dependency_status():
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0" server_version = "AniCliWeb/1.0"
remote_token_cookie_name = "ani_cli_web_token"
def _context(self): def _context(self):
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None) context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
@@ -116,6 +118,8 @@ class Handler(BaseHTTPRequestHandler):
self.headers.get("X-AniCli-Web-Token") self.headers.get("X-AniCli-Web-Token")
or self.headers.get("x-ani-cli-web-token") or self.headers.get("x-ani-cli-web-token")
or Handler._bearer_token(self) or Handler._bearer_token(self)
or Handler._cookie_token(self)
or Handler._query_token(self)
or "" or ""
) )
@@ -125,6 +129,62 @@ class Handler(BaseHTTPRequestHandler):
return "" return ""
return header[len("Bearer ") :].strip() return header[len("Bearer ") :].strip()
def _cookie_token(self):
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
if not raw:
return ""
try:
cookie = SimpleCookie()
cookie.load(raw)
except Exception:
return ""
morsel = cookie.get(Handler.remote_token_cookie_name)
if not morsel:
return ""
return morsel.value.strip()
def _query_token(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
for key in ("token", "remote_token"):
values = params.get(key) or []
for value in values:
text = str(value).strip()
if text:
return text
return ""
def _remote_cookie_bootstrap_response(self, parsed):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return False
if not remote_access_allowed() or not remote_access_token_configured():
return False
if Handler._cookie_token(self):
return False
token = Handler._query_token(self)
if not token or not remote_access_token_matches(token):
return False
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None)
params.pop("remote_token", None)
query = urlencode(params, doseq=True)
location = urlunparse(("", "", parsed.path or "/", "", query, ""))
cookie = (
f"{Handler.remote_token_cookie_name}={token}; "
"Path=/; HttpOnly; SameSite=Lax"
)
Handler.write_response_bytes(
self,
b"",
HTTPStatus.SEE_OTHER,
{
"Location": location,
"Set-Cookie": cookie,
},
)
return True
def ensure_client_access(self): def ensure_client_access(self):
client_host = Handler._effective_client_host(self) client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host): if client_address_is_local(client_host):
@@ -143,6 +203,8 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
try: try:
if Handler._remote_cookie_bootstrap_response(self, parsed):
return
self.ensure_client_access() self.ensure_client_access()
if parsed.path == "/": if parsed.path == "/":
self.html(Handler._context(self).index_html) self.html(Handler._context(self).index_html)
+51
View File
@@ -51,6 +51,9 @@ class DummyHandler:
self.error_status = None self.error_status = None
self.error_message = None self.error_message = None
self.file_path = None self.file_path = None
self.response_status = None
self.response_headers = []
self.wfile = io.BytesIO()
def ensure_client_access(self): def ensure_client_access(self):
return APP.Handler.ensure_client_access(self) return APP.Handler.ensure_client_access(self)
@@ -75,6 +78,15 @@ class DummyHandler:
def exception(self, exc): def exception(self, exc):
return APP.Handler.exception(self, exc) return APP.Handler.exception(self, exc)
def send_response(self, status):
self.response_status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
return None
class NetworkGuardTests(unittest.TestCase): class NetworkGuardTests(unittest.TestCase):
def test_runtime_bootstrap_is_deferred_until_initialize(self): def test_runtime_bootstrap_is_deferred_until_initialize(self):
@@ -111,6 +123,45 @@ class NetworkGuardTests(unittest.TestCase):
): ):
APP.Handler.ensure_client_access(handler) APP.Handler.ensure_client_access(handler)
def test_remote_access_accepts_valid_cookie_token_for_non_local_clients(self):
handler = DummyHandler("/api/config")
handler.client_address = ("192.168.1.25", 8421)
handler.headers["Cookie"] = "ani_cli_web_token=secret-token"
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_remote_access_accepts_valid_query_token_for_non_local_clients(self):
handler = DummyHandler("/api/config?token=secret-token")
handler.client_address = ("192.168.1.25", 8421)
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_remote_get_bootstraps_cookie_and_redirects_when_query_token_valid(self):
handler = DummyHandler("/?token=secret-token")
handler.client_address = ("192.168.1.25", 8421)
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.do_GET(handler)
headers = dict(handler.response_headers)
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
self.assertEqual(headers.get("Location"), "/")
self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", ""))
def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self): def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self):
handler = DummyHandler("/api/config") handler = DummyHandler("/api/config")
handler.client_address = ("127.0.0.1", 8421) handler.client_address = ("127.0.0.1", 8421)