fix: 收紧 GUI 安全边界

This commit is contained in:
2026-06-20 13:51:06 +08:00
parent ff73470142
commit a1b791e6ef
10 changed files with 112 additions and 67 deletions
+6 -1
View File
@@ -104,7 +104,7 @@ Docker 编排包含两个服务:
## 开发检查 ## 开发检查
```bash ```bash
find webgui/web/assets/js -name '*.js' -exec node --check {} \; find webgui/web/assets/js -name '*.js' -exec sh -c 'for f; do node --input-type=module --check < "$f" || exit 1; done' sh {} +
python3 -m py_compile webgui/api/server.py python3 -m py_compile webgui/api/server.py
python3 -m unittest discover -s webgui/api -p '*_test.py' python3 -m unittest discover -s webgui/api -p '*_test.py'
curl -sS http://127.0.0.1:5580/ curl -sS http://127.0.0.1:5580/
@@ -117,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui` 如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
能像 `rclone gui` 那样独立运行),可以: 能像 `rclone gui` 那样独立运行),可以:
注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动
`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整
的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过
`?jobsApi=<url>` 指给前端。
```bash ```bash
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下 # 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
ln -s ../../webgui rclone/cmd/webgui ln -s ../../webgui rclone/cmd/webgui
+39 -27
View File
@@ -16,6 +16,7 @@ import traceback
import urllib.error import urllib.error
import urllib.request import urllib.request
from calendar import monthrange from calendar import monthrange
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
@@ -178,11 +179,22 @@ def connect() -> sqlite3.Connection:
return conn return conn
@contextmanager
def db_connect():
with db_lock:
conn = connect()
try:
with conn:
yield conn
finally:
conn.close()
def init_db() -> None: def init_db() -> None:
db_dir = os.path.dirname(DB_PATH) db_dir = os.path.dirname(DB_PATH)
if db_dir: if db_dir:
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
with db_lock, connect() as conn: with db_connect() as conn:
conn.executescript( conn.executescript(
""" """
CREATE TABLE IF NOT EXISTS job_schedules ( CREATE TABLE IF NOT EXISTS job_schedules (
@@ -322,7 +334,7 @@ def row_to_one_time_job(row: sqlite3.Row) -> dict[str, Any]:
def fetch_job(schedule_id: int) -> dict[str, Any]: def fetch_job(schedule_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -331,13 +343,13 @@ def fetch_job(schedule_id: int) -> dict[str, Any]:
def list_jobs() -> list[dict[str, Any]]: def list_jobs() -> list[dict[str, Any]]:
refresh_running_jobs() refresh_running_jobs()
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall() rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall()
return [row_to_job(row) for row in rows] return [row_to_job(row) for row in rows]
def fetch_one_time_job(job_id: int) -> dict[str, Any]: def fetch_one_time_job(job_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -346,7 +358,7 @@ def fetch_one_time_job(job_id: int) -> dict[str, Any]:
def list_one_time_jobs() -> list[dict[str, Any]]: def list_one_time_jobs() -> list[dict[str, Any]]:
refresh_one_time_jobs() refresh_one_time_jobs()
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM one_time_jobs ORDER BY updated_at DESC, id DESC").fetchall() rows = conn.execute("SELECT * FROM one_time_jobs ORDER BY updated_at DESC, id DESC").fetchall()
return [row_to_one_time_job(row) for row in rows] return [row_to_one_time_job(row) for row in rows]
@@ -383,7 +395,7 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]:
interval_seconds = legacy_interval_seconds(schedule_kind) interval_seconds = legacy_interval_seconds(schedule_kind)
now = iso(utcnow()) now = iso(utcnow())
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO job_schedules INSERT INTO job_schedules
@@ -423,7 +435,7 @@ def create_one_time_job(payload: dict[str, Any]) -> dict[str, Any]:
raise ApiError(400, "src and dst are required") raise ApiError(400, "src and dst are required")
now = iso(utcnow()) now = iso(utcnow())
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO one_time_jobs INSERT INTO one_time_jobs
@@ -452,7 +464,7 @@ def legacy_interval_seconds(schedule_kind: str) -> int:
def delete_job(schedule_id: int) -> None: def delete_job(schedule_id: int) -> None:
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,)) cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,))
if cur.rowcount == 0: if cur.rowcount == 0:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -461,7 +473,7 @@ def delete_job(schedule_id: int) -> None:
def manual_run(schedule_id: int) -> dict[str, Any]: def manual_run(schedule_id: int) -> dict[str, Any]:
with scheduler_lock: with scheduler_lock:
refresh_running_jobs() refresh_running_jobs()
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -472,7 +484,7 @@ def manual_run(schedule_id: int) -> dict[str, Any]:
def stop_job(schedule_id: int) -> dict[str, Any]: def stop_job(schedule_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -485,7 +497,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
now = iso(now_dt) now = iso(now_dt)
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -512,7 +524,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
def manual_run_one_time_job(job_id: int) -> dict[str, Any]: def manual_run_one_time_job(job_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -523,7 +535,7 @@ def manual_run_one_time_job(job_id: int) -> dict[str, Any]:
def stop_one_time_job(job_id: int) -> dict[str, Any]: def stop_one_time_job(job_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -534,7 +546,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]:
rc_post("job/stop", {"jobid": jobid}) rc_post("job/stop", {"jobid": jobid})
now = iso(utcnow()) now = iso(utcnow())
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE one_time_jobs UPDATE one_time_jobs
@@ -551,7 +563,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]:
def delete_one_time_job(job_id: int) -> None: def delete_one_time_job(job_id: int) -> None:
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,)) cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,))
if cur.rowcount == 0: if cur.rowcount == 0:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -619,7 +631,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
error = str(exc) error = str(exc)
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO job_runs INSERT INTO job_runs
@@ -647,7 +659,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO job_runs INSERT INTO job_runs
@@ -675,7 +687,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
def start_one_time_job(job_id: int) -> None: def start_one_time_job(job_id: int) -> None:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -699,7 +711,7 @@ def start_one_time_job(job_id: int) -> None:
except Exception as exc: except Exception as exc:
error = str(exc) error = str(exc)
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE one_time_jobs UPDATE one_time_jobs
@@ -717,7 +729,7 @@ def start_one_time_job(job_id: int) -> None:
return return
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE one_time_jobs UPDATE one_time_jobs
@@ -762,7 +774,7 @@ def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any]
def refresh_running_jobs() -> None: def refresh_running_jobs() -> None:
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall() rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
for row in rows: for row in rows:
@@ -776,7 +788,7 @@ def refresh_running_jobs() -> None:
def refresh_one_time_jobs() -> None: def refresh_one_time_jobs() -> None:
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall() rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall()
for row in rows: for row in rows:
@@ -797,7 +809,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
name = status_name(status) name = status_name(status)
if name == "running": if name == "running":
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -821,7 +833,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
error = status.get("error") error = status.get("error")
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -854,7 +866,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
name = status_name(status) name = status_name(status)
if name == "running": if name == "running":
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE one_time_jobs UPDATE one_time_jobs
@@ -869,7 +881,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
return return
error = status.get("error") error = status.get("error")
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE one_time_jobs UPDATE one_time_jobs
@@ -887,7 +899,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
def run_due_jobs() -> None: def run_due_jobs() -> None:
with scheduler_lock: with scheduler_lock:
now = iso(utcnow()) now = iso(utcnow())
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT * FROM job_schedules SELECT * FROM job_schedules
+6 -6
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when // the top-nav active state in sync, and re-renders chrome strings when
// the locale changes. // the locale changes.
import { escapeHtml, onRoute } from "./state.js?v=review-fixes-1"; import { escapeHtml, onRoute } from "./state.js?v=security-fixes-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=review-fixes-1"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=security-fixes-1";
import { renderRemotes } from "./views/remotes.js?v=review-fixes-1"; import { renderRemotes } from "./views/remotes.js?v=security-fixes-1";
import { renderBrowse } from "./views/browser.js?v=review-fixes-1"; import { renderBrowse } from "./views/browser.js?v=security-fixes-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=review-fixes-1"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=security-fixes-1";
import { import {
renderConfigureNew, renderConfigureNew,
renderConfigureEdit, renderConfigureEdit,
} from "./views/configure.js?v=review-fixes-1"; } from "./views/configure.js?v=security-fixes-1";
const views = { const views = {
remotes: renderRemotes, remotes: renderRemotes,
+15 -8
View File
@@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass");
let authHeader = null; let authHeader = null;
if (AUTH_USER && AUTH_PASS) { if (AUTH_USER && AUTH_PASS) {
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`); authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
scrubAuthQuery();
} }
export function rcURL() { export function rcURL() {
@@ -30,6 +31,13 @@ export function isNoAuth() {
return !authHeader; return !authHeader;
} }
function scrubAuthQuery() {
const clean = new URL(location.href);
clean.searchParams.delete("user");
clean.searchParams.delete("pass");
history.replaceState(null, "", `${clean.pathname}${clean.search}${clean.hash}`);
}
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws. // POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
export async function post(path, body = {}, options = {}) { export async function post(path, body = {}, options = {}) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
@@ -72,14 +80,13 @@ export async function uploadFile(fs, remote, files) {
export function downloadURL(remoteFs, remotePath, fileName) { export function downloadURL(remoteFs, remotePath, fileName) {
const base = RC_BASE.replace(/\/$/, ""); const base = RC_BASE.replace(/\/$/, "");
// rc server serves remote files at /<remote>:<path> // rc server serves remote files at /<remote>:<path>
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, ""); const path = [remoteFs, remotePath, fileName]
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`; .filter(Boolean)
let url = `${base}/${path}`; .flatMap((part) => String(part).split("/"))
if (authHeader) { .filter((part) => part !== "")
// Embed basic auth into the URL so the browser can fetch it directly. .map(encodeURIComponent)
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`); .join("/");
} return `${base}/${path}`;
return url;
} }
async function parseResponse(res, path, { allowError = false } = {}) { async function parseResponse(res, path, { allowError = false } = {}) {
+8 -4
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js?v=review-fixes-1"; import { post, uploadFile, downloadURL } from "../rc.js?v=security-fixes-1";
import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=review-fixes-1"; import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=security-fixes-1";
import { t } from "../i18n.js?v=review-fixes-1"; import { t } from "../i18n.js?v=security-fixes-1";
export async function renderBrowse({ remote, path }) { export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -169,7 +169,7 @@ function row(fs, path, item) {
return ` return `
<tr> <tr>
<td class="col-name"> <td class="col-name">
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a> <a href="${escapeAttr(downloadURL(fs, path, item.Name))}" download="${escapeAttr(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
</td> </td>
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td> <td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td> <td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
@@ -296,3 +296,7 @@ export function openModal(title, fields, onSubmit) {
if (first) first.focus(); if (first) first.focus();
}); });
} }
function escapeAttr(s) {
return escapeHtml(s);
}
+8 -7
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner // OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal. // and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js?v=review-fixes-1"; import { post } from "../rc.js?v=security-fixes-1";
import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1"; import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
import { t } from "../i18n.js?v=review-fixes-1"; import { t } from "../i18n.js?v=security-fixes-1";
// --- Route entrypoints --- // --- Route entrypoints ---
@@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) {
document.getElementById("app").innerHTML = ` document.getElementById("app").innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_provider", provider)}</p> <p>${t("error.no_such_provider", escapeHtml(provider))}</p>
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p> <p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
</div>`; </div>`;
return; return;
@@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) {
app.innerHTML = ` app.innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_remote", remote)}</p> <p>${t("error.no_such_remote", escapeHtml(remote))}</p>
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p> <p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
</div>`; </div>`;
return; return;
@@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
const requiresOAuth = provider.Options.some( const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive), (o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
); );
const blocksCreate = requiresOAuth && !isEdit;
// Partition options into required, optional-basic, optional-advanced. // Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide); const required = provider.Options.filter((o) => o.Required && !o.Hide);
@@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}> <button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
${isEdit ? t("configure.save") : t("configure.create")} ${isEdit ? t("configure.save") : t("configure.create")}
</button> </button>
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a> <a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
@@ -255,7 +256,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
if (requiresOAuth) return; if (blocksCreate) return;
const name = form.elements._remote_name.value.trim(); const name = form.elements._remote_name.value.trim();
if (!name) return; if (!name) return;
+4 -4
View File
@@ -1,14 +1,14 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. // views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post } from "../rc.js?v=review-fixes-1"; import { post } from "../rc.js?v=security-fixes-1";
import { import {
toast, toast,
formatBytes, formatBytes,
formatSpeed, formatSpeed,
formatDuration, formatDuration,
escapeHtml, escapeHtml,
} from "../state.js?v=review-fixes-1"; } from "../state.js?v=security-fixes-1";
import { t } from "../i18n.js?v=review-fixes-1"; import { t } from "../i18n.js?v=security-fixes-1";
import { import {
createOneTimeJob, createOneTimeJob,
createRecurringJob, createRecurringJob,
@@ -21,7 +21,7 @@ import {
runRecurringJobNow, runRecurringJobNow,
stopOneTimeJob, stopOneTimeJob,
stopRecurringJob, stopRecurringJob,
} from "../jobs_api.js?v=review-fixes-1"; } from "../jobs_api.js?v=security-fixes-1";
let pollTimer = null; let pollTimer = null;
const LOCAL_FS_VALUE = "__local__"; const LOCAL_FS_VALUE = "__local__";
+3 -3
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD. // views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js?v=review-fixes-1"; import { post } from "../rc.js?v=security-fixes-1";
import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1"; import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
import { t } from "../i18n.js?v=review-fixes-1"; import { t } from "../i18n.js?v=security-fixes-1";
export async function renderRemotes() { export async function renderRemotes() {
const app = document.getElementById("app"); const app = document.getElementById("app");
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css"> <link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css"> <link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=review-fixes-1"> <link rel="stylesheet" href="/assets/styles/components.css?v=security-fixes-1">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div> <div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script type="module" src="/assets/js/app.js?v=review-fixes-1"></script> <script type="module" src="/assets/js/app.js?v=security-fixes-1"></script>
</body> </body>
</html> </html>
+21 -5
View File
@@ -171,7 +171,7 @@ network.
return fmt.Errorf("failed to make password: %w", err) return fmt.Errorf("failed to make password: %w", err)
} }
opt.Auth.BasicPass = randomPass opt.Auth.BasicPass = randomPass
fs.Infof(nil, "No password specified. Using random password: %s", randomPass) fs.Infof(nil, "No password specified. Using random password for this session")
} }
} }
@@ -200,10 +200,13 @@ network.
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
// Build the launch URL: always pass ?url=<rcURL> so the SPA can // Build the launch URL: always pass ?url=<rcURL> so the SPA can
// discover the RC base; embed user/pass only when auth is on. // discover the RC base.
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
fs.Logf(nil, "GUI available at %s", loginURL) fs.Logf(nil, "GUI available at %s", safeLoginURL(loginURL))
if !opt.NoAuth {
fs.Logf(nil, "GUI authentication user: %s", opt.Auth.BasicUser)
}
if !noOpenBrowser { if !noOpenBrowser {
if err := open.Start(loginURL); err != nil { if err := open.Start(loginURL); err != nil {
fs.Errorf(nil, "failed to open GUI in browser: %v", err) fs.Errorf(nil, "failed to open GUI in browser: %v", err)
@@ -287,8 +290,9 @@ func guiHandler(srcFS iofs.FS) http.Handler {
// buildLoginURL constructs the URL the browser should open. The query // buildLoginURL constructs the URL the browser should open. The query
// string always carries the RC API base URL so the SPA can find it. // string always carries the RC API base URL so the SPA can find it.
// When auth is enabled, user/pass and a /login hash are added so the // When auth is enabled, user/pass and a /login hash are added so the SPA can
// SPA can present credentials to the cross-port RC server. // present credentials to the cross-port RC server, then remove them from the
// visible address bar on load.
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u, err := url.Parse(guiBaseURL) u, err := url.Parse(guiBaseURL)
if err != nil { if err != nil {
@@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }
func safeLoginURL(loginURL string) string {
u, err := url.Parse(loginURL)
if err != nil {
return loginURL
}
q := u.Query()
q.Del("user")
q.Del("pass")
u.RawQuery = q.Encode()
return u.String()
}