diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9019419..ed49dc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install clang-format + - name: Install clang-format 18 run: | sudo apt-get update - sudo apt-get install -y clang-format + sudo apt-get install -y clang-format-18 - name: Check formatting + env: + CLANG_FORMAT: clang-format-18 run: | chmod +x scripts/format.sh ./scripts/format.sh --check @@ -122,7 +124,17 @@ jobs: if (Test-Path "README.md") { Copy-Item "README.md" "$dir/" } if (Test-Path "LICENSE") { Copy-Item "LICENSE" "$dir/" } if (Test-Path "config") { Copy-Item "config" "$dir/config" -Recurse } - if (Test-Path "extension") { Copy-Item "extension" "$dir/extension" -Recurse } + # Prefer POST_BUILD-stamped companion; fall back to repo then stamp to tag X.Y.Z. + if (Test-Path "build/RelWithDebInfo/extension") { + Copy-Item "build/RelWithDebInfo/extension" "$dir/extension" -Recurse + } elseif (Test-Path "extension") { + Copy-Item "extension" "$dir/extension" -Recurse + } + if (Test-Path "$dir/extension/manifest.json") { + $ver = "${{ steps.ver.outputs.version }}" + & cmake "-DQP_EXT_DIR=$dir/extension" "-DQP_EXT_VERSION=$ver" -P "cmake/stamp_extension_version.cmake" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } Compress-Archive -Path "$dir/*" -DestinationPath "$name.zip" -Force Get-Item "$name.zip" | Format-List Name, Length, FullName diff --git a/README.md b/README.md index fce01d8..28f4b95 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,15 @@ Pure **Win32 + C++17**. No third-party libraries. Built to debug, trace, and ext - **Fire on key release** so modifiers are up before automation runs - Send-to-AI workflow: 1. Select-all + copy from the focused editor - 2. Activate Chrome (Dev/Beta/stable) / Edge - 3. New tab → open AI URL (default [meta.ai](https://www.meta.ai/)) + 2. Prefer **Chrome MV3 companion** (DOM paste into the composer) + 3. Fallback: activate Chrome (Dev/Beta/stable) / Edge → new tab → AI URL 4. Adaptive wait (window title + **UI Automation** chat input) 5. Paste `prompt + editor text` 6. Restore clipboard - Insert-only mode (paste template into the current field) - File + debugger logging; optional `titles.log` for mining real window titles -- Templates / hotkeys in `include/config.hpp` (file config planned later) +- Bindings live in `%LOCALAPPDATA%\QiuckPrompts\qiuckprompts.ini` (seed: `config/qiuckprompts.ini`) +- Binding / update URLs must be `https://` ## Configuration file @@ -31,7 +32,8 @@ Pure **Win32 + C++17**. No third-party libraries. Built to debug, trace, and ext Seeded on first run from the install template (`\config\qiuckprompts.ini`). Updates never overwrite the user file. Backups live in `%LOCALAPPDATA%\QiuckPrompts\backups\`. -**Logs:** `%LOCALAPPDATA%\QiuckPrompts\logs\` (`qiuckprompts.log`, `titles.log`; rotated ~5 MiB × 4 files). +**Logs:** `%LOCALAPPDATA%\QiuckPrompts\logs\` (`qiuckprompts.log`, `titles.log`; rotated ~5 MiB × 4 files). +Default `log_level=info` (length only). Payload / clipboard / editor previews are **debug/trace** — they can contain secrets. Tray → **Open config** / **Open data folder**. Override path: `--config=D:\path\qiuckprompts.ini` @@ -124,12 +126,13 @@ build\Debug\qiuckprompts.exe --self-test | Flag | Meaning | |------|---------| | `--console` | Live logs on a console | -| `--log-level=LEVEL` | `trace` \| `debug` \| `info` \| `warn` \| `error` | +| `--log-level=LEVEL` | `trace` \| `debug` \| `info` \| `warn` \| `error` (default **info**) | | `--log-file=PATH` | Override log path | -| `--ai-url=URL` | Default AI chat URL | +| `--ai-url=URL` | Default AI chat URL (**https:// only**) | +| `--update-url=URL` | Velopack feed directory (**https:// only**) | | `--browser-hint=TEXT` | Prefer matching window/path (default `Chrome Dev`) | | `--page-title-hint=TEXT` | Title must contain this (auto from URL if empty) | -| `--page-ready-timeout=MS` | Max wait for page/input (default 15000) | +| `--page-ready-timeout=MS` | Max wait for page/input (default 10000) | | `--page-ready-min=MS` | Min wait after navigate (default 500) | | `--no-uia` | Title-only wait (disable UI Automation) | | `--no-extension` | Skip Chrome companion; UIA-only path | @@ -227,7 +230,9 @@ Use feature branches and squash-merge PRs into `master` — details in [CONTRIBU | Path | Role | |------|------| -| `include/config.hpp` | Templates, hotkeys, workflow knobs | +| `config/qiuckprompts.ini` | Shipped template (hotkeys + prompts). Live copy is AppData. | +| `%LOCALAPPDATA%\QiuckPrompts\` | User config, logs, backups, NM host, stable extension | +| `include/config.hpp` | Built-in fallback bindings + workflow knobs | | `src/workflow.cpp` | Send-to-AI pipeline (extension → UIA fallback) | | `src/ext_bridge.cpp` | Named pipe + native-messaging host relay | | `extension/` | MV3 companion (DOM composer paste) | @@ -247,7 +252,7 @@ For tuning `pageTitleHint` / browser matching: 1. Run the app 2. Open AI tabs in Chrome 3. Tray → **Sample window titles now** -4. Tray → **Open titles.log** → `build\Debug\logs\titles.log` +4. Tray → **Open titles.log** → `%LOCALAPPDATA%\QiuckPrompts\logs\titles.log` Lines are tagged `TITLE_SAMPLE` with stable `where=` fields. @@ -256,7 +261,8 @@ Lines are tagged `TITLE_SAMPLE` with stable `where=` fields. - User data lives under **`%LOCALAPPDATA%\QiuckPrompts`** so installers/updates cannot clobber config or logs. - Chrome does **not** expose the chat box as a Win32 `HWND`. Prefer the **MV3 companion** (DOM); readiness otherwise uses the **UI Automation** tree plus the tab title. - Hotkeys **arm on press** and **run on release** so Ctrl/Alt are up before Select-all/Copy/Paste. -- No Qt/WPF/Electron — message-only window + tray only. +- Hidden top-level HWND (not `HWND_MESSAGE`) so a second launch can FindWindow + take over. +- Binding `url=` and `update_url=` must be `https://`. The updater refuses HTTPS→HTTP redirects. ## License diff --git a/config/qiuckprompts.ini b/config/qiuckprompts.ini index d732da9..257b217 100644 --- a/config/qiuckprompts.ini +++ b/config/qiuckprompts.ini @@ -19,7 +19,7 @@ [settings] browser_hint=Chrome Dev -log_level=debug +log_level=info fence_editor_text=1 ; Prefer MV3 companion (DOM paste). Falls back to UIA if extension not connected. prefer_extension=1 @@ -27,6 +27,8 @@ prefer_extension=1 ; extension_id=aodehlngahndannepofbddnacfaldmih ; Velopack update feed (directory with releases.win.json). Empty = GitHub latest/download. ; update_url=https://github.com/summeroff/qiuckprompts/releases/latest/download +; Binding url= and update_url= must be https:// (http / javascript / file rejected). +; log_level=info (default) logs sizes only. debug/trace may preview clipboard/editor text. ; Start tray app at logon via HKCU Run when set to 1 (applied on next launch). ; Tray → Start with Windows toggles the Run key only (does not rewrite this ini). ; start_with_windows=0 (default) does not clear a Run key you enabled from the tray. diff --git a/extension/background.js b/extension/background.js index b7810f7..3a090b2 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2,6 +2,28 @@ const HOST = 'com.qiuckprompts.host'; +// Keep in sync with extension/manifest.json host_permissions. +const ALLOWED_AI_ORIGINS = new Set([ + 'https://www.meta.ai', + 'https://meta.ai', + 'https://gemini.google.com', + 'https://grok.com', + 'https://x.com', + 'https://chatgpt.com', + 'https://claude.ai', + 'https://www.perplexity.ai', + 'https://copilot.microsoft.com', +]); + +function isAllowedAiUrl(url) { + try { + const u = new URL(String(url || '')); + return u.protocol === 'https:' && ALLOWED_AI_ORIGINS.has(u.origin); + } catch { + return false; + } +} + let port = null; let reconnectTimer = null; @@ -230,6 +252,10 @@ async function onNativeMessage(msg) { reply(msg, { ok: false, error: 'missing url' }); return; } + if (!isAllowedAiUrl(url)) { + reply(msg, { ok: false, error: 'url must be https on a known AI origin' }); + return; + } let wantOrigin = ''; try { wantOrigin = new URL(url).origin; @@ -292,6 +318,10 @@ async function onNativeMessage(msg) { const url = String(msg.url || ''); const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000)); const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false; + if (!isAllowedAiUrl(url)) { + reply(msg, { ok: false, error: 'url must be https on a known AI origin' }); + return; + } let wantOrigin = ''; try { wantOrigin = new URL(url).origin; diff --git a/include/config.hpp b/include/config.hpp index e8af3af..aa14b18 100644 --- a/include/config.hpp +++ b/include/config.hpp @@ -113,7 +113,7 @@ struct WorkflowConfig struct AppConfig { std::wstring logPath; - LogLevel logLevel = LogLevel::Debug; + LogLevel logLevel = LogLevel::Info; bool console = false; int pasteDelayMs = 200; WorkflowConfig workflow; diff --git a/include/util.hpp b/include/util.hpp index 37fa1f5..ac53780 100644 --- a/include/util.hpp +++ b/include/util.hpp @@ -42,6 +42,12 @@ std::string WideToUtf8(const std::wstring& wide); std::wstring Trim(const std::wstring& s); std::wstring ToLower(const std::wstring& s); +// True iff url is already-trimmed https:// with a non-empty hostname. +// No DNS. Rejects http, file, javascript, outer whitespace, and empty hosts +// (e.g. https://:443/path). Callers that accept padded input must Trim first +// and store the trimmed value. +bool IsHttpsUrl(const std::wstring& url); + // Hotkey struct HotkeySpec { diff --git a/scripts/format.sh b/scripts/format.sh index 9985dc7..583df99 100644 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -9,9 +9,10 @@ case "${1:-}" in --check|-n|--dry-run) CHECK=1 ;; esac -CF="${CLANG_FORMAT:-clang-format}" -if ! command -v "$CF" >/dev/null 2>&1; then - for c in clang-format-18 clang-format-17 clang-format-16 clang-format-15; do +CF="${CLANG_FORMAT:-}" +if [[ -z "$CF" ]] || ! command -v "$CF" >/dev/null 2>&1; then + CF="" + for c in clang-format-18 clang-format clang-format-17 clang-format-16 clang-format-15; do if command -v "$c" >/dev/null 2>&1; then CF="$c"; break; fi done fi diff --git a/src/app.cpp b/src/app.cpp index 462c2db..653f092 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -512,8 +512,19 @@ int App::Run(HINSTANCE instance, int argc, wchar_t** argv) cfg_.startWithWindows = false; cliForceAutostartOff = true; } + const std::wstring arg = argv[i]; + const std::wstring logPref = L"--log-level="; + if (arg.rfind(logPref, 0) == 0) + { + cfg_.logLevel = Logger::ParseLevel(arg.substr(logPref.size())); + } else if (arg == L"--log-level" && i + 1 < argc && argv[i + 1]) + { + cfg_.logLevel = Logger::ParseLevel(argv[++i]); + } } } + Logger::Instance().SetLevel(cfg_.logLevel); + QP_LOG_INFO(L"log level effective=%s", Logger::LevelName(cfg_.logLevel)); // Autostart (HKCU Run): // - CLI --start-with-windows / --no-start-with-windows always apply @@ -588,7 +599,7 @@ int App::Run(HINSTANCE instance, int argc, wchar_t** argv) MessageBoxW(nullptr, (L"Failed to register hotkeys:\n" + err + L"\n\nAnother app may own these chords. " - L"Edit GetBuiltInBindings() in config.hpp and rebuild.") + L"Edit %LOCALAPPDATA%\\QiuckPrompts\\qiuckprompts.ini and restart.") .c_str(), QP_APP_DISPLAY_W, MB_OK | MB_ICONWARNING); // Continue running so user can still open About / Exit from tray. @@ -659,9 +670,42 @@ int App::RunSelfTest() AppConfig ac; expect(ac.hotkeyTrigger == HotkeyTriggerMode::OnRelease, L"default OnRelease"); expect(ac.hotkeyReleaseTimeoutMs > 0, L"release timeout default"); + expect(ac.logLevel == LogLevel::Info, L"default log level Info"); + + expect(IsHttpsUrl(L"https://www.meta.ai/"), L"IsHttpsUrl meta"); + expect(IsHttpsUrl(L"HTTPS://gemini.google.com/app"), L"IsHttpsUrl case"); + expect(!IsHttpsUrl(L"http://www.meta.ai/"), L"IsHttpsUrl rejects http"); + expect(!IsHttpsUrl(L"javascript:alert(1)"), L"IsHttpsUrl rejects javascript"); + expect(!IsHttpsUrl(L"file:///c:/x"), L"IsHttpsUrl rejects file"); + expect(!IsHttpsUrl(L"https://"), L"IsHttpsUrl rejects empty host"); + expect(!IsHttpsUrl(L"https://:443/path"), L"IsHttpsUrl rejects empty host :443"); + expect(!IsHttpsUrl(L" https://www.meta.ai/"), L"IsHttpsUrl rejects leading space"); + expect(!IsHttpsUrl(L"https://www.meta.ai/ "), L"IsHttpsUrl rejects trailing space"); + expect(IsHttpsUrl(L"https://[::1]/"), L"IsHttpsUrl ipv6"); + expect(!IsHttpsUrl(L""), L"IsHttpsUrl empty"); + + { + AppConfig cli; + std::wstring e; + wchar_t* fakeHttp[] = {const_cast(L"qiuckprompts.exe"), + const_cast(L"--ai-url=http://evil.example/")}; + expect(!ParseCommandLine(2, fakeHttp, cli, &e), L"ParseCommandLine rejects http --ai-url"); + e.clear(); + wchar_t* fakeOk[] = {const_cast(L"qiuckprompts.exe"), + const_cast(L"--ai-url=https://gemini.google.com/app")}; + expect(ParseCommandLine(2, fakeOk, cli, &e) && IsHttpsUrl(cli.workflow.defaultAiUrl), + L"ParseCommandLine accepts https --ai-url"); + e.clear(); + wchar_t* fakePad[] = {const_cast(L"qiuckprompts.exe"), + const_cast(L"--ai-url= https://gemini.google.com/app")}; + expect(ParseCommandLine(2, fakePad, cli, &e) && + cli.workflow.defaultAiUrl == L"https://gemini.google.com/app", + L"ParseCommandLine trims padded --ai-url"); + } WorkflowConfig wc; expect(!wc.defaultAiUrl.empty(), L"default AI URL set"); + expect(IsHttpsUrl(wc.defaultAiUrl), L"default AI URL is https"); expect(!wc.browserTitleHint.empty(), L"browser hint set"); expect(wc.pageReadyTimeoutMs > 0, L"pageReadyTimeoutMs > 0"); expect(wc.pageReadyTimeoutMs <= 10000, L"default pageReadyTimeoutMs <= 10s"); diff --git a/src/config.cpp b/src/config.cpp index 3f78bb4..05cffe9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -458,7 +458,13 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin if (!get(L"fence_editor_text").empty()) cfg.workflow.fenceEditorText = get(L"fence_editor_text") != L"0"; if (!get(L"default_ai_url").empty()) - cfg.workflow.defaultAiUrl = get(L"default_ai_url"); + { + const std::wstring u = Trim(get(L"default_ai_url")); + if (IsHttpsUrl(u)) + cfg.workflow.defaultAiUrl = u; + else + QP_LOG_WARN(L"config: default_ai_url is not https — ignored"); + } if (!get(L"prefer_extension").empty()) cfg.workflow.preferExtension = get(L"prefer_extension") != L"0"; if (!get(L"paste_even_if_not_ready").empty()) @@ -471,7 +477,13 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin if (!get(L"extension_id").empty()) cfg.extensionId = Trim(get(L"extension_id")); if (!get(L"update_url").empty()) - cfg.updateUrl = Trim(get(L"update_url")); + { + const std::wstring u = Trim(get(L"update_url")); + if (IsHttpsUrl(u)) + cfg.updateUrl = u; + else + QP_LOG_WARN(L"config: update_url is not https — ignored"); + } if (!get(L"start_with_windows").empty()) cfg.startWithWindows = get(L"start_with_windows") != L"0"; } @@ -493,7 +505,7 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin b.templateId = name; b.label = get(L"label").empty() ? name : get(L"label"); b.service = get(L"service"); - b.aiUrl = get(L"url"); + b.aiUrl = Trim(get(L"url")); b.pageTitleHint = get(L"title_hint"); b.action = ActionKind::SendToAi; @@ -574,6 +586,11 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin if (b.aiUrl.empty()) b.aiUrl = cfg.workflow.defaultAiUrl; + if (!IsHttpsUrl(b.aiUrl)) + { + QP_LOG_WARN(L"config: [%s] url is not https — skip", name.c_str()); + continue; + } QP_LOG_INFO( L"config: binding [%s] %s service=%s url=%s image=%d capture=%d prompt=%zu wchar", @@ -675,7 +692,14 @@ bool ParseCommandLine(int argc, wchar_t** argv, AppConfig& cfg, std::wstring* er } if (TakeEqValue(i, argc, argv, arg, L"--ai-url", v)) { - cfg.workflow.defaultAiUrl = v; + const std::wstring u = Trim(v); + if (!IsHttpsUrl(u)) + { + if (error) + *error = L"--ai-url must be https://..."; + return false; + } + cfg.workflow.defaultAiUrl = u; continue; } if (TakeEqValue(i, argc, argv, arg, L"--browser-hint", v)) @@ -711,7 +735,14 @@ bool ParseCommandLine(int argc, wchar_t** argv, AppConfig& cfg, std::wstring* er } if (TakeEqValue(i, argc, argv, arg, L"--update-url", v)) { - cfg.updateUrl = v; + const std::wstring u = Trim(v); + if (!IsHttpsUrl(u)) + { + if (error) + *error = L"--update-url must be https://..."; + return false; + } + cfg.updateUrl = u; continue; } if (arg == L"--start-with-windows") diff --git a/src/ext_bridge.cpp b/src/ext_bridge.cpp index 3dc6ba2..ca411a6 100644 --- a/src/ext_bridge.cpp +++ b/src/ext_bridge.cpp @@ -47,8 +47,43 @@ struct PipeSecurity } SECURITY_ATTRIBUTES* Attrs() { return sd ? &sa : nullptr; } + bool Ok() const { return sd != nullptr; } }; +std::wstring CanonicalPath(const std::wstring& path) +{ + if (path.empty()) + return {}; + wchar_t buf[32768]{}; + const DWORD n = GetFullPathNameW(path.c_str(), static_cast(sizeof(buf) / sizeof(buf[0])), + buf, nullptr); + if (n == 0 || n >= static_cast(sizeof(buf) / sizeof(buf[0]))) + return path; + return buf; +} + +// Named-pipe clients must be this same exe (tray dummy connect or --native-messaging-host). +bool PipeClientIsSelf(HANDLE pipe) +{ + ULONG pid = 0; + if (!GetNamedPipeClientProcessId(pipe, &pid) || pid == 0) + return false; + if (pid == GetCurrentProcessId()) + return true; + HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!proc) + return false; + wchar_t img[32768]{}; + DWORD n = static_cast(sizeof(img) / sizeof(img[0])); + const BOOL ok = QueryFullProcessImageNameW(proc, 0, img, &n); + CloseHandle(proc); + if (!ok || img[0] == L'\0') + return false; + const std::wstring want = CanonicalPath(GetExePath()); + const std::wstring got = CanonicalPath(img); + return !want.empty() && !got.empty() && _wcsicmp(want.c_str(), got.c_str()) == 0; +} + bool ReadExact(HANDLE h, void* buf, DWORD n, DWORD timeoutMs) { BYTE* p = static_cast(buf); @@ -339,6 +374,16 @@ bool ExtBridge::Start(std::wstring* error) if (running_.load()) return true; + { + PipeSecurity probe; + if (!probe.Ok()) + { + if (error) + *error = L"pipe SDDL setup failed — refusing open named pipe"; + return false; + } + } + stopEvent_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); if (!stopEvent_) { @@ -415,6 +460,12 @@ DWORD WINAPI ExtBridge::ServerThreadMain(void* self) void ExtBridge::ServerLoop() { PipeSecurity pipeSec; + if (!pipeSec.Ok()) + { + QP_LOG_ERROR(L"ext_bridge: pipe SDDL failed — not creating an open pipe"); + running_ = false; + return; + } while (running_.load()) { HANDLE pipe = CreateNamedPipeW(QP_EXT_BRIDGE_PIPE_W, PIPE_ACCESS_DUPLEX, @@ -442,6 +493,14 @@ void ExtBridge::ServerLoop() continue; } + if (!PipeClientIsSelf(pipe)) + { + QP_LOG_WARN(L"ext_bridge: rejected pipe client (not this exe)"); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + continue; + } + { std::lock_guard lock(ioMutex_); pipe_ = pipe; diff --git a/src/main.cpp b/src/main.cpp index b0ccad5..f2cd9a1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,24 +33,26 @@ void PrintHelp() L" qiuckprompts.exe [options]\n" L"\n" L"Default hotkeys (left Ctrl+Alt, right-hand letter):\n" - L" Ctrl+Alt+J Grammar check\n" - L" Ctrl+Alt+K Fact check\n" - L" Ctrl+Alt+L Summarize\n" - L" Ctrl+Alt+I Explain simply\n" - L" Ctrl+Alt+O Code review\n" + L" Ctrl+Alt+J Grammar quick → Meta\n" + L" Ctrl+Alt+K Improve message (facts + grammar) → Gemini\n" + L" Ctrl+Alt+L Idea collab → Grok\n" + L" Ctrl+Alt+I Screenshot on clipboard → Gemini\n" + L" Ctrl+Alt+O Grammar in context ({{CONTEXT}}) → Gemini\n" + L" Ctrl+Alt+M Song from clipboard → ChatGPT (Suno)\n" L"\n" - L"Workflow: select-all → copy editor → activate Chrome Dev →\n" - L" new tab → open AI URL → paste prompt + text\n" + L"Workflow: select-all → copy editor → Chrome companion DOM paste\n" + L" (fallback: activate Chrome Dev → new tab → AI URL → UIA paste)\n" L"\n" L"Options:\n" L" --console Live logs on a console\n" - L" --log-level=LEVEL trace|debug|info|warn|error\n" + L" --log-level=LEVEL trace|debug|info|warn|error (default info)\n" L" --log-file=PATH Override log path\n" L" --paste-delay=MS Insert-only clipboard restore delay\n" - L" --ai-url=URL Default AI chat URL (meta.ai)\n" + L" --ai-url=URL Default AI chat URL (must be https://)\n" + L" --update-url=URL Velopack feed directory (must be https://)\n" L" --browser-hint=TEXT Window/path hint (default: Chrome Dev)\n" L" --page-title-hint=TEXT Page title must contain this (auto from URL)\n" - L" --page-ready-timeout=MS Max wait for page/input (default 15000)\n" + L" --page-ready-timeout=MS Max wait for page/input (default 10000)\n" L" --page-ready-min=MS Min wait after navigate (default 500)\n" L" --no-uia Disable UI Automation; title-only wait\n" L" --no-extension Skip Chrome companion; UIA-only paste path\n" diff --git a/src/updater.cpp b/src/updater.cpp index 9e8d4dc..ba9da0d 100644 --- a/src/updater.cpp +++ b/src/updater.cpp @@ -172,7 +172,7 @@ bool HttpOpenGet(const std::wstring& url, HttpSession& hs, std::wstring* error) return false; } - DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_ALWAYS; + DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; WinHttpSetOption(hs.req, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)); if (!WinHttpSendRequest(hs.req, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, @@ -474,6 +474,14 @@ bool CheckForUpdates(const std::wstring& feedUrl, UpdateCheckResult& out, std::w // strip trailing slash for join while (!feed.empty() && (feed.back() == L'/' || feed.back() == L'\\')) feed.pop_back(); + if (!IsHttpsUrl(feed)) + { + out.ok = false; + out.detail = L"update feed must use HTTPS: " + feed; + if (error) + *error = out.detail; + return false; + } const std::wstring jsonUrl = JoinUrl(feed, L"releases.win.json"); QP_LOG_INFO(L"updater: fetching %s", jsonUrl.c_str()); diff --git a/src/util.cpp b/src/util.cpp index 0eb5fc3..9a4ec5b 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace qp @@ -408,6 +409,33 @@ std::wstring Trim(const std::wstring& s) return s.substr(b, e - b); } +bool IsHttpsUrl(const std::wstring& url) +{ + if (url.empty() || url != Trim(url)) + return false; + constexpr size_t kPrefixLen = 8; + if (url.size() <= kPrefixLen) + return false; + if (_wcsnicmp(url.c_str(), L"https://", kPrefixLen) != 0) + return false; + for (wchar_t c : url) + { + if (c < 32 || c == L' ' || c == L'\t') + return false; + } + size_t i = kPrefixLen; + const size_t slash = url.find_first_of(L"/?#", i); + const size_t at = url.find(L'@', i); + if (at != std::wstring::npos && (slash == std::wstring::npos || at < slash)) + i = at + 1; + if (i >= url.size() || (slash != std::wstring::npos && i >= slash)) + return false; + const wchar_t host0 = url[i]; + if (!(iswalnum(host0) || host0 == L'[')) + return false; + return true; +} + std::wstring ToLower(const std::wstring& s) { std::wstring out = s; diff --git a/src/workflow.cpp b/src/workflow.cpp index 1867f5a..18c4b88 100644 --- a/src/workflow.cpp +++ b/src/workflow.cpp @@ -182,6 +182,8 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) const std::wstring url = !req.aiUrl.empty() ? req.aiUrl : cfg_.defaultAiUrl; if (url.empty()) return fail(L"AI URL is empty"); + if (!IsHttpsUrl(url)) + return fail(L"AI URL must be https:// (got: " + url + L")"); if (req.promptBody.empty()) return fail(L"prompt template is empty"); @@ -255,8 +257,9 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) { return fail(error && !error->empty() ? *error : L"clipboard read failed"); } - QP_LOG_INFO(L"workflow: captured text (%zu wchar) preview='%s'", editorText.size(), - PayloadPreview(editorText, 80).c_str()); + QP_LOG_INFO(L"workflow: captured text (%zu wchar)", editorText.size()); + QP_LOG_DEBUG(L"workflow: captured text preview='%s'", + PayloadPreview(editorText, 80).c_str()); } else { QP_LOG_INFO(L"workflow: skip editor capture");