fix: 收紧 WebGUI 默认安全配置

This commit is contained in:
2026-06-20 11:30:50 +08:00
parent aff11b77b6
commit bce0c6188e
4 changed files with 184 additions and 18 deletions
+43 -7
View File
@@ -27,6 +27,15 @@ RCLONE_RC_URL = os.environ.get("RCLONE_RC_URL", "http://rclone:8080").rstrip("/"
HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0")
PORT = int(os.environ.get("JOBS_API_PORT", "8081"))
SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15"))
MAX_BODY_BYTES = int(os.environ.get("JOBS_API_MAX_BODY_BYTES", str(64 * 1024)))
ALLOWED_ORIGINS = {
origin.strip().rstrip("/")
for origin in os.environ.get(
"JOBS_API_ALLOWED_ORIGINS",
"http://localhost:5580,http://127.0.0.1:5580",
).split(",")
if origin.strip()
}
VALID_ACTIONS = {"copy", "sync", "move"}
VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"}
@@ -646,11 +655,22 @@ def scheduler_loop() -> None:
traceback.print_exc()
def scheduler_initial_tick() -> None:
try:
scheduler_tick()
except Exception:
traceback.print_exc()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_OPTIONS(self) -> None:
self.send_json(204, None)
try:
self.require_allowed_origin()
self.send_json(204, None)
except ApiError as exc:
self.send_json(exc.status, {"error": exc.message})
def do_GET(self) -> None:
self.handle_request("GET")
@@ -663,6 +683,7 @@ class Handler(BaseHTTPRequestHandler):
def handle_request(self, method: str) -> None:
try:
self.require_allowed_origin()
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
parts = [p for p in path.split("/") if p]
@@ -704,8 +725,22 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self.send_json(500, {"error": str(exc)})
def require_allowed_origin(self) -> None:
origin = self.headers.get("Origin")
if not origin:
return
if origin.rstrip("/") not in ALLOWED_ORIGINS:
raise ApiError(403, "origin not allowed")
def read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or "0")
try:
length = int(self.headers.get("Content-Length") or "0")
except ValueError:
raise ApiError(400, "invalid Content-Length") from None
if length < 0:
raise ApiError(400, "invalid Content-Length")
if length > MAX_BODY_BYTES:
raise ApiError(413, "request body too large")
if length == 0:
return {}
raw = self.rfile.read(length)
@@ -719,8 +754,11 @@ class Handler(BaseHTTPRequestHandler):
def send_json(self, status: int, body: Any) -> None:
raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
origin = self.headers.get("Origin")
self.send_response(status)
self.send_header("Access-Control-Allow-Origin", "*")
if origin and origin.rstrip("/") in ALLOWED_ORIGINS:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -747,10 +785,8 @@ def main() -> None:
init_db()
thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True)
thread.start()
try:
scheduler_tick()
except Exception:
traceback.print_exc()
initial_thread = threading.Thread(target=scheduler_initial_tick, name="job-scheduler-initial", daemon=True)
initial_thread.start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}")
try:
+102
View File
@@ -0,0 +1,102 @@
import unittest
from io import BytesIO
from datetime import datetime, timezone
from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at
class ComputeNextRunAtTest(unittest.TestCase):
def test_daily_uses_same_day_when_time_is_future(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-20T23:30:00Z",
)
def test_daily_moves_to_next_day_when_time_has_passed(self):
after = datetime(2026, 6, 20, 23, 30, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-21T23:30:00Z",
)
def test_weekly_uses_next_target_weekday(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) # Saturday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-22T09:00:00Z",
)
def test_weekly_rolls_forward_a_week_after_same_day_time_passed(self):
after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) # Monday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-29T09:00:00Z",
)
def test_monthly_uses_next_valid_monthday(self):
after = datetime(2026, 1, 30, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-01-31T23:00:00Z",
)
def test_monthly_skips_short_months_for_day_31(self):
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-03-31T23:00:00Z",
)
def test_timezone_offset_is_applied_from_local_schedule_time(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:00", 8 * 60, after),
"2026-06-20T15:00:00Z",
)
def test_invalid_monthday_raises(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
with self.assertRaises(ValueError):
compute_next_run_at("monthly", "23:00", 0, after, monthday=32)
class HandlerSecurityTest(unittest.TestCase):
def test_allowed_origin_is_accepted(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://localhost:5580"}})()
Handler.require_allowed_origin(handler)
def test_disallowed_origin_is_rejected(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://example.invalid"}})()
with self.assertRaises(ApiError) as ctx:
Handler.require_allowed_origin(handler)
self.assertEqual(ctx.exception.status, 403)
def test_request_body_size_is_limited(self):
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(MAX_BODY_BYTES + 1)},
"rfile": BytesIO(),
},
)()
with self.assertRaises(ApiError) as ctx:
Handler.read_json(handler)
self.assertEqual(ctx.exception.status, 413)
def test_read_json_accepts_small_object(self):
body = b'{"ok":true}'
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(len(body))},
"rfile": BytesIO(body),
},
)()
self.assertEqual(Handler.read_json(handler), {"ok": True})
if __name__ == "__main__":
unittest.main()