diff --git a/AGENTS.md b/AGENTS.md index 4ed350b..4c5fb80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo 1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。 2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。 3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。 -4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。新接口必须做到;已有接口还没全部做到,改到哪个补哪个。 +4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。 5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。 6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。 7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e0afebb..2ec0d7c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,6 +111,8 @@ covered by the `styles.css` manifest. lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points. `api/node_material.py` saves one canvas node's current values as a `node_parameter_selection` material (the current-node panel's "save"). +- `api/local_guard.py` decides which requests only this computer may make (every change under + `/anomalous/`; other devices may look), as one middleware; see backend.md. - `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and `api/folder_types.py` own the formerly mixed utility route families. - `model_policies.py` owns shared backend rename and protected-category policy. @@ -294,7 +296,8 @@ covered by the `styles.css` manifest. seeking, search, paging, download and deletion, showing the speech/voice/seed recorded in each file, plus a section for unsaved temp previews that can be copied into `output/audio/`. -- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels) +- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels; + `remote_view.js` adds the header's "view only" chip when opened from another device) and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page navigation (`goTo`): the domain each page needs, which pages have a list and whether diff --git a/CHANGELOG.md b/CHANGELOG.md index 61390d2..a23edf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 📈 Anomalous Model Browser Changelog +## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12 + +### 🔒 Other devices can look, not change (别的设备只能看,不能改) +- When ComfyUI is started with `--listen`, other devices on your network can open it. Until now they could also use most of Anomalous Model Browser's actions: delete or rename models, save and delete workflows, combos and prompts, start downloads and scans, export a backup of all your data, translate with your translation key, change settings, switch the plugin's version. Now every change answers only the computer ComfyUI runs on; other devices can still browse models, pictures and workflows. +- A window opened from another device shows **View only** next to the page title; pressing a button that changes something there says why it did nothing. +- A web page that reaches ComfyUI through another name for this computer, or a proxy, counts as another device too. + ## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09 ### 🧭 New layout (新布局) diff --git a/README.md b/README.md index b9ff8f2..f1e48cf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE) -[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.0_Beta-blue?style=for-the-badge)](CHANGELOG.md) +[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.1_Beta-blue?style=for-the-badge)](CHANGELOG.md) [![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/) [![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw) diff --git a/api/__init__.py b/api/__init__.py index 5cea44a..c8571e0 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,7 +1,7 @@ from . import ( activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary, - translation_routes, version_manager, + local_guard, translation_routes, version_manager, ) from .scanner import * from .config import * @@ -29,6 +29,10 @@ async def no_cache_extension_middleware(request, handler): def setup_routes(app): if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares: app.middlewares.append(no_cache_extension_middleware) + # Other devices on the network may look; only this computer may change things (local_guard.py). + if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares: + app.middlewares.append(local_guard.local_only_middleware) + app.router.add_get('/anomalous/access', local_guard.api_access) app.router.add_get('/anomalous/folders', model_catalog.api_get_folders) app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types) app.router.add_get('/anomalous/models', model_catalog.api_get_models) diff --git a/api/local_guard.py b/api/local_guard.py new file mode 100644 index 0000000..da26fca --- /dev/null +++ b/api/local_guard.py @@ -0,0 +1,82 @@ +"""What only this computer may do. ComfyUI started with --listen lets other devices on the +network open the page: they may look (lists, searches, pictures, covers), but every change +under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups, +version switches, saved settings) answers only requests from the computer ComfyUI runs on +(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than +GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not +a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no +proxy, naming a loopback host; checking the Host also keeps out a page that reached this server +under another name (DNS rebinding).""" + +import ipaddress +import urllib.parse + +from aiohttp import web + +PREFIX = "/anomalous/" +PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host") +LOCAL_NAMES = ("localhost",) +# Reads sent as POST (their lists are long): open to other devices too. They write nothing. +OPEN_POSTS = frozenset({ + "/anomalous/resolve_paths_to_previews", + "/anomalous/resolve_hash_batch", + "/anomalous/inspect_image_material", +}) +# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders. +LOCAL_GETS = frozenset({ + "/anomalous/backup/file", + "/anomalous/version/releases", + "/anomalous/import/folders", +}) +# Routes that answer other devices themselves (MCP replies in JSON-RPC). +OWN_CHECK = frozenset({"/anomalous/mcp"}) +READS = ("GET", "HEAD", "OPTIONS") +MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。" + " Only the computer ComfyUI runs on can do this; other devices can only look.") + + +def loopback_host(name): + if not name: + return False + name = name.strip("[]").lower() + if name in LOCAL_NAMES: + return True + try: + return ipaddress.ip_address(name).is_loopback + except ValueError: + return False + + +def is_local(request): + """The request comes from this computer, directly, and names a loopback host.""" + if not loopback_host(request.remote or ""): + return False + if any(header in request.headers for header in PROXY_HEADERS): + return False + return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname) + + +def needs_local(method, path): + """Whether a request to `path` with `method` must come from this computer.""" + if not path.startswith(PREFIX) or path in OWN_CHECK: + return False + if method.upper() in READS: + return path in LOCAL_GETS + return path not in OPEN_POSTS + + +def refusal(): + return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only", + "message": MESSAGE}, status=403) + + +@web.middleware +async def local_only_middleware(request, handler): + if needs_local(request.method, request.path) and not is_local(request): + return refusal() + return await handler(request) + + +async def api_access(request): + """GET /anomalous/access - whether this page may change things: {"local": bool}.""" + return web.json_response({"local": is_local(request)}) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index d5ea217..424f829 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -40,6 +40,20 @@ inventory recognizes GGUF and PTH case-insensitively in addition to the existing list formats. This route-level inventory does not expand global model-format, scanner, metadata-parser, recovery, or cloud-identification support. +## Requests from other devices + +ComfyUI started with `--listen` lets other devices on the network open the page. They may +look; every change under `/anomalous/` answers only the computer ComfyUI runs on. One +middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes: +a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST: +`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open +unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`). +`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers +(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a +page that reached the server under another name (DNS rebinding) is refused too. A refusal is +HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page +whether it is local; `remote_view.js` then shows a "view only" chip in the header. + ## Storage ownership Runtime UI settings and newly stored API keys live in `api/config.json`. diff --git a/web/modules/locales.js b/web/modules/locales.js index 7c52012..dce6f13 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -571,6 +571,8 @@ export const i18n = { detailBackAssistant: '⬅ 返回助手面板', detailJumpToBottom: '跳到底部', detailEdit: '编辑', + remoteViewChip: '仅浏览', + remoteViewHint: '这个窗口是从别的设备打开的:可以浏览;删除、移动、下载、扫描、保存这类改动,只能在运行 ComfyUI 的电脑上做。', detailNotesTitle: '📓 专属备注 (Notes)', detailEditNotes: '编辑备注', detailGeneratedGallery: '查看历史生成作品', @@ -2467,6 +2469,8 @@ export const i18n = { detailBackAssistant: '⬅ Back to Assistant', detailJumpToBottom: 'Jump to bottom', detailEdit: 'Edit', + remoteViewChip: 'View only', + remoteViewHint: 'This window was opened from another device: you can look around; deleting, moving, downloading, scanning, saving and other changes can only be made on the computer ComfyUI runs on.', detailNotesTitle: '📓 Custom Notes', detailEditNotes: 'Edit Notes', detailGeneratedGallery: 'View Generated Gallery', diff --git a/web/modules/remote_view.js b/web/modules/remote_view.js new file mode 100644 index 0000000..ad62f83 --- /dev/null +++ b/web/modules/remote_view.js @@ -0,0 +1,24 @@ +/** + * Opened from another device (ComfyUI started with --listen): a chip in the header says this + * window can look but not change anything (the server refuses changes from other devices, + * api/local_guard.py). Nothing shows on the computer ComfyUI runs on. + */ + +import { translate as t } from './locales.js'; + +/** Asks the server once whether this page is on its computer; when not, marks the window and adds the chip to `group`. */ +export async function markRemoteView(container, group) { + try { + const response = await fetch('/anomalous/access', { cache: 'no-store' }); + const data = response.ok ? await response.json() : null; + if (!data || data.local !== false) return; + } catch { + return; // an older server without the route: nothing to say + } + container.classList.add('anomalous-remote-view'); + const chip = document.createElement('span'); + chip.className = 'anomalous-remote-chip'; + chip.textContent = t('remoteViewChip'); + chip.title = t('remoteViewHint'); + group.append(chip); +} diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js index 50d1920..ca88150 100644 --- a/web/modules/ui_sidebar.js +++ b/web/modules/ui_sidebar.js @@ -3,6 +3,7 @@ * The browser window (rail, list column, header, page panels) and the model folder list. */ +import { markRemoteView } from './remote_view.js'; import { translate } from './locales.js'; import { escapeHtml } from './safe_dom.js'; import { showUpdateGuide } from './ui_update_guide.js'; @@ -157,6 +158,7 @@ export function createDOM() { rightGroup.append(updateNoticeBtn, dockBtn, closeBtn); header.append(leftGroup, centerGroup, rightGroup); + markRemoteView(container, leftGroup); // opened from another device: "view only" this.grid = panel('anomalous-grid', ''); this.detailPanel = panel('anomalous-detail'); diff --git a/web/styles.css b/web/styles.css index 8ed73d6..1c36db0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -13,7 +13,7 @@ @import url("./styles/12-tts-setup.css?v=20261002-theme-1"); @import url("./styles/13-script-director.css?v=20261002-voiceover-2"); @import url("./styles/14-version-manager.css?v=20261002-theme-1"); -@import url("./styles/15-shell-rail.css?v=20261003-feedback-2"); +@import url("./styles/15-shell-rail.css?v=20261012-remote"); @import url("./styles/16-activity.css?v=20261002-theme-1"); @import url("./styles/17-model-types.css?v=20261002-theme-1"); @import url("./styles/18-scan-page.css?v=20261003-folders-1"); diff --git a/web/styles/15-shell-rail.css b/web/styles/15-shell-rail.css index 7434cb0..761ffb1 100644 --- a/web/styles/15-shell-rail.css +++ b/web/styles/15-shell-rail.css @@ -517,3 +517,16 @@ color: var(--amb-btn-primary-text); font-weight: 600; } + +/* Opened from another device (remote_view.js): a quiet "view only" chip beside the page title. */ +.anomalous-remote-chip { + flex-shrink: 0; + margin-left: 8px; + padding: 2px 9px; + border: 1px dashed var(--amb-border-strong); + border-radius: 999px; + color: var(--amb-text-muted); + font-size: 11.5px; + white-space: nowrap; + cursor: help; +}