Add browser-friendly remote token bootstrap
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user