Add browser-friendly remote token bootstrap
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.30.3`
|
||||
Current version: `0.30.4`
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -223,6 +223,14 @@ Non-local clients must then send either:
|
||||
- `Authorization: Bearer <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.
|
||||
|
||||
This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost.
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ from uuid import uuid4
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||
APP_NAME = "ani-cli-web"
|
||||
VERSION = "0.30.3"
|
||||
VERSION = "0.30.4"
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||
ALLANIME_REFERER = "https://allmanga.to"
|
||||
|
||||
+63
-1
@@ -8,10 +8,11 @@ import os
|
||||
import shutil
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
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 (
|
||||
ANI_CLI,
|
||||
@@ -68,6 +69,7 @@ def dependency_status():
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AniCliWeb/1.0"
|
||||
remote_token_cookie_name = "ani_cli_web_token"
|
||||
|
||||
def _context(self):
|
||||
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")
|
||||
or self.headers.get("x-ani-cli-web-token")
|
||||
or Handler._bearer_token(self)
|
||||
or Handler._cookie_token(self)
|
||||
or Handler._query_token(self)
|
||||
or ""
|
||||
)
|
||||
|
||||
@@ -125,6 +129,62 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return ""
|
||||
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):
|
||||
client_host = Handler._effective_client_host(self)
|
||||
if client_address_is_local(client_host):
|
||||
@@ -143,6 +203,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
if Handler._remote_cookie_bootstrap_response(self, parsed):
|
||||
return
|
||||
self.ensure_client_access()
|
||||
if parsed.path == "/":
|
||||
self.html(Handler._context(self).index_html)
|
||||
|
||||
+51
@@ -51,6 +51,9 @@ class DummyHandler:
|
||||
self.error_status = None
|
||||
self.error_message = None
|
||||
self.file_path = None
|
||||
self.response_status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def ensure_client_access(self):
|
||||
return APP.Handler.ensure_client_access(self)
|
||||
@@ -75,6 +78,15 @@ class DummyHandler:
|
||||
def 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):
|
||||
def test_runtime_bootstrap_is_deferred_until_initialize(self):
|
||||
@@ -111,6 +123,45 @@ class NetworkGuardTests(unittest.TestCase):
|
||||
):
|
||||
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):
|
||||
handler = DummyHandler("/api/config")
|
||||
handler.client_address = ("127.0.0.1", 8421)
|
||||
|
||||
Reference in New Issue
Block a user