From 0c06b4a55a231837c04eb00d704210398fa4a753 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 18:01:54 -0500 Subject: [PATCH 1/4] fix(security): harden credential storage and transmission Problem: credential and cookie files were world-readable (0644), passwords transited via `CP_CREDENTIALS` env var (visible in `/proc/PID/environ`), and Kattis/USACO echoed passwords back through stdout unnecessarily. Solution: set 0600 permissions on `cp-nvim.json` and `cookies.json` after every write, pass credentials via stdin pipe instead of env var, and stop emitting passwords in ndjson from Kattis/USACO `LoginResult` (CSES token emission unchanged). --- lua/cp/cache.lua | 2 ++ lua/cp/credentials.lua | 1 + lua/cp/scraper.lua | 6 +++--- scrapers/base.py | 6 ++++-- scrapers/kattis.py | 2 -- scrapers/usaco.py | 2 -- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 9448910..7ff1824 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -57,6 +57,7 @@ function M.load() if vim.fn.filereadable(cache_file) == 0 then vim.fn.writefile({}, cache_file) + vim.fn.setfperm(cache_file, 'rw-------') loaded = true return end @@ -107,6 +108,7 @@ function M.save() local encoded = vim.json.encode(cache_data) local lines = vim.split(encoded, '\n') vim.fn.writefile(lines, cache_file) + vim.fn.setfperm(cache_file, 'rw-------') end) end diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index a1258c4..f644ecf 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -117,6 +117,7 @@ function M.logout(platform) if ok and type(data) == 'table' then data[platform] = nil vim.fn.writefile({ vim.fn.json_encode(data) }, cookie_file) + vim.fn.setfperm(cookie_file, 'rw-------') end end logger.log(display .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 02f20b3..4ad46bc 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -344,7 +344,7 @@ function M.login(platform, credentials, on_status, callback) local done = false run_scraper(platform, 'login', {}, { ndjson = true, - env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, + stdin = vim.json.encode(credentials), on_event = function(ev) if ev.credentials ~= nil and next(ev.credentials) ~= nil then require('cp.cache').set_credentials(platform, ev.credentials) @@ -392,9 +392,9 @@ function M.submit( local done = false run_scraper(platform, 'submit', { contest_id, problem_id, language, source_file }, { ndjson = true, - env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, + stdin = vim.json.encode(credentials), on_event = function(ev) - if ev.credentials ~= nil then + if ev.credentials ~= nil and next(ev.credentials) ~= nil then require('cp.cache').set_credentials(platform, ev.credentials) end if ev.status ~= nil then diff --git a/scrapers/base.py b/scrapers/base.py index 035495a..e98990c 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -36,6 +36,7 @@ def save_platform_cookies(platform: str, data: Any) -> None: existing = {} existing[platform] = data _COOKIE_FILE.write_text(json.dumps(existing)) + _COOKIE_FILE.chmod(0o600) def clear_platform_cookies(platform: str) -> None: @@ -43,6 +44,7 @@ def clear_platform_cookies(platform: str) -> None: existing = json.loads(_COOKIE_FILE.read_text()) existing.pop(platform, None) _COOKIE_FILE.write_text(json.dumps(existing)) + _COOKIE_FILE.chmod(0o600) except Exception: pass @@ -160,7 +162,7 @@ class BaseScraper(ABC): ).model_dump_json() ) return 1 - creds_raw = os.environ.get("CP_CREDENTIALS", "{}") + creds_raw = sys.stdin.read() try: credentials = json.loads(creds_raw) except json.JSONDecodeError: @@ -173,7 +175,7 @@ class BaseScraper(ABC): return 0 if result.success else 1 case "login": - creds_raw = os.environ.get("CP_CREDENTIALS", "{}") + creds_raw = sys.stdin.read() try: credentials = json.loads(creds_raw) except json.JSONDecodeError: diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 8b0099f..4417628 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -415,7 +415,6 @@ class KattisScraper(BaseScraper): return LoginResult( success=True, error="", - credentials={"username": username, "password": password}, ) print(json.dumps({"status": "logging_in"}), flush=True) @@ -426,7 +425,6 @@ class KattisScraper(BaseScraper): return LoginResult( success=True, error="", - credentials={"username": username, "password": password}, ) diff --git a/scrapers/usaco.py b/scrapers/usaco.py index e463881..d878886 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -533,7 +533,6 @@ class USACOScraper(BaseScraper): return LoginResult( success=True, error="", - credentials={"username": username, "password": password}, ) print(json.dumps({"status": "logging_in"}), flush=True) @@ -549,7 +548,6 @@ class USACOScraper(BaseScraper): return LoginResult( success=True, error="", - credentials={"username": username, "password": password}, ) From e3c81e895a07239945d577d33c07060e0e663027 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 18:12:55 -0500 Subject: [PATCH 2/4] feat(credentials): re-prompt on bad credentials and improve cancel UX Problem: After a failed login attempt, the user had to re-run `:CP login` manually. Cancel messages lacked context, and credential prompts in `submit.lua` used raw platform IDs instead of display names. Solution: `prompt_and_login` now recurses after failure so the user is re-prompted until they cancel or succeed. `submit.lua` extracts the scraper call into `do_submit` and re-prompts via `prompt_credentials` on `bad_credentials`. Username prompts show ` to cancel`, and cancel messages include the platform display name. --- lua/cp/credentials.lua | 7 ++++--- lua/cp/submit.lua | 25 +++++++++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index f644ecf..78d260f 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -14,16 +14,16 @@ local STATUS_MESSAGES = { ---@param platform string ---@param display string local function prompt_and_login(platform, display) - vim.ui.input({ prompt = '[cp.nvim]: ' .. display .. ' username: ' }, function(username) + vim.ui.input({ prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, function(username) if not username or username == '' then - logger.log('Cancelled', { level = vim.log.levels.WARN }) + logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') vim.fn.inputrestore() if not password or password == '' then - logger.log('Cancelled', { level = vim.log.levels.WARN }) + logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) return end @@ -50,6 +50,7 @@ local function prompt_and_login(platform, display) display .. ' login failed: ' .. (constants.LOGIN_ERRORS[err] or err), { level = vim.log.levels.ERROR } ) + prompt_and_login(platform, display) end end) end) diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 68b9b90..0b203c8 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -19,13 +19,14 @@ local function prompt_credentials(platform, callback) callback(saved) return end - vim.ui.input({ prompt = platform .. ' username: ' }, function(username) + local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + vim.ui.input({ prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, function(username) if not username or username == '' then logger.log('Submit cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() - local password = vim.fn.inputsecret(platform .. ' password: ') + local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') vim.fn.inputrestore() vim.cmd.redraw() if not password or password == '' then @@ -86,7 +87,7 @@ function M.submit(opts) end end - prompt_credentials(platform, function(creds) + local function do_submit(creds) vim.cmd.update() require('cp.scraper').submit( @@ -112,16 +113,24 @@ function M.submit(opts) local err = result and result.error or 'unknown error' if err == 'bad_credentials' or err:match('^Login failed') then cache.clear_credentials(platform) + logger.log( + 'Submit failed: ' .. (constants.LOGIN_ERRORS[err] or err), + { level = vim.log.levels.ERROR } + ) + prompt_credentials(platform, do_submit) + else + logger.log( + 'Submit failed: ' .. (constants.LOGIN_ERRORS[err] or err), + { level = vim.log.levels.ERROR } + ) end - logger.log( - 'Submit failed: ' .. (constants.LOGIN_ERRORS[err] or err), - { level = vim.log.levels.ERROR } - ) end end) end ) - end) + end + + prompt_credentials(platform, do_submit) end return M From 693d3bf1728ff6ced49cc31847c50889c32c37b0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 18:13:12 -0500 Subject: [PATCH 3/4] feat(commands): standalone :CP login/logout with auto-restore Problem: `:CP login` and `:CP logout` only worked as `:CP login`. Users with an active platform or in a registered buffer had to type the platform name redundantly. Solution: Parse standalone `login`/`logout`/`signup` as action commands with `requires_context = true`, resolving the platform from state (or auto-restoring from the current buffer). Add `vim.cmd.redraw()` after auto-restore so the screen updates before any prompt. Add `login`/ `logout` to top-level completion when a platform is active. --- doc/cp.nvim.txt | 2 ++ lua/cp/commands/init.lua | 32 ++++++++++++++++++++++++++------ plugin/cp.lua | 4 ++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 4fc684c..670bdab 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -453,6 +453,7 @@ COMMANDS *cp-commands* any previously saved credentials. If [platform] is omitted, uses the active platform. Examples: > + :CP login :CP login atcoder :CP login codeforces < @@ -460,6 +461,7 @@ COMMANDS *cp-commands* Remove stored credentials for a platform. If [platform] is omitted, uses the active platform. Examples: > + :CP logout :CP logout atcoder < :CP {platform} signup diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 9ea34a4..8731a8f 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -326,6 +326,10 @@ local function parse_command(args) end end + if (first == 'login' or first == 'logout' or first == 'signup') and #args == 1 then + return { type = 'action', action = first, requires_context = true, platform = nil } + end + if #args == 1 then return { type = 'problem_jump', @@ -378,6 +382,7 @@ function M.handle_command(opts) if not restore.restore_from_current_file() then return end + vim.cmd.redraw() end local setup = require('cp.setup') @@ -421,20 +426,35 @@ function M.handle_command(opts) end vim.ui.open(url) elseif cmd.action == 'login' then - if not check_platform_enabled(cmd.platform) then + local p = cmd.platform or state.get_platform() + if not p then + logger.log('No platform active. Usage: :CP login', { level = vim.log.levels.ERROR }) return end - require('cp.credentials').login(cmd.platform) + if not check_platform_enabled(p) then + return + end + require('cp.credentials').login(p) elseif cmd.action == 'logout' then - if not check_platform_enabled(cmd.platform) then + local p = cmd.platform or state.get_platform() + if not p then + logger.log('No platform active. Usage: :CP logout', { level = vim.log.levels.ERROR }) return end - require('cp.credentials').logout(cmd.platform) + if not check_platform_enabled(p) then + return + end + require('cp.credentials').logout(p) elseif cmd.action == 'signup' then - local url = constants.SIGNUP_URLS[cmd.platform] + local p = cmd.platform or state.get_platform() + if not p then + logger.log('No platform active. Usage: :CP signup', { level = vim.log.levels.ERROR }) + return + end + local url = constants.SIGNUP_URLS[p] if not url then logger.log( - ("No signup URL available for '%s'"):format(cmd.platform), + ("No signup URL available for '%s'"):format(p), { level = vim.log.levels.WARN } ) return diff --git a/plugin/cp.lua b/plugin/cp.lua index 531a5f3..b1430f2 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -43,6 +43,10 @@ end, { vim.list_extend(candidates, platforms) table.insert(candidates, 'cache') table.insert(candidates, 'pick') + if platform then + table.insert(candidates, 'login') + table.insert(candidates, 'logout') + end if platform and contest_id then vim.list_extend( candidates, From 1dcce37f51bea109d41d5b73b710f0cdede75024 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 18:14:10 -0500 Subject: [PATCH 4/4] ci: format --- lua/cp/commands/init.lua | 20 ++++++---- lua/cp/credentials.lua | 79 +++++++++++++++++++++------------------- lua/cp/submit.lua | 35 ++++++++++-------- 3 files changed, 73 insertions(+), 61 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 8731a8f..c13b196 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -428,7 +428,10 @@ function M.handle_command(opts) elseif cmd.action == 'login' then local p = cmd.platform or state.get_platform() if not p then - logger.log('No platform active. Usage: :CP login', { level = vim.log.levels.ERROR }) + logger.log( + 'No platform active. Usage: :CP login', + { level = vim.log.levels.ERROR } + ) return end if not check_platform_enabled(p) then @@ -438,7 +441,10 @@ function M.handle_command(opts) elseif cmd.action == 'logout' then local p = cmd.platform or state.get_platform() if not p then - logger.log('No platform active. Usage: :CP logout', { level = vim.log.levels.ERROR }) + logger.log( + 'No platform active. Usage: :CP logout', + { level = vim.log.levels.ERROR } + ) return end if not check_platform_enabled(p) then @@ -448,15 +454,15 @@ function M.handle_command(opts) elseif cmd.action == 'signup' then local p = cmd.platform or state.get_platform() if not p then - logger.log('No platform active. Usage: :CP signup', { level = vim.log.levels.ERROR }) + logger.log( + 'No platform active. Usage: :CP signup', + { level = vim.log.levels.ERROR } + ) return end local url = constants.SIGNUP_URLS[p] if not url then - logger.log( - ("No signup URL available for '%s'"):format(p), - { level = vim.log.levels.WARN } - ) + logger.log(("No signup URL available for '%s'"):format(p), { level = vim.log.levels.WARN }) return end vim.ui.open(url) diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 78d260f..4f22038 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -14,47 +14,50 @@ local STATUS_MESSAGES = { ---@param platform string ---@param display string local function prompt_and_login(platform, display) - vim.ui.input({ prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, function(username) - if not username or username == '' then - logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) - return - end - vim.fn.inputsave() - local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') - vim.fn.inputrestore() - if not password or password == '' then - logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) - return - end + vim.ui.input( + { prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, + function(username) + if not username or username == '' then + logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) + return + end + vim.fn.inputsave() + local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') + vim.fn.inputrestore() + if not password or password == '' then + logger.log(display .. ' login cancelled', { level = vim.log.levels.WARN }) + return + end - local credentials = { username = username, password = password } + local credentials = { username = username, password = password } - local scraper = require('cp.scraper') - scraper.login(platform, credentials, function(ev) - vim.schedule(function() - local msg = STATUS_MESSAGES[ev.status] or ev.status - logger.log(display .. ': ' .. msg, { level = vim.log.levels.INFO, override = true }) + local scraper = require('cp.scraper') + scraper.login(platform, credentials, function(ev) + vim.schedule(function() + local msg = STATUS_MESSAGES[ev.status] or ev.status + logger.log(display .. ': ' .. msg, { level = vim.log.levels.INFO, override = true }) + end) + end, function(result) + vim.schedule(function() + if result.success then + cache.set_credentials(platform, credentials) + logger.log( + display .. ' login successful', + { level = vim.log.levels.INFO, override = true } + ) + else + local err = result.error or 'unknown error' + cache.clear_credentials(platform) + logger.log( + display .. ' login failed: ' .. (constants.LOGIN_ERRORS[err] or err), + { level = vim.log.levels.ERROR } + ) + prompt_and_login(platform, display) + end + end) end) - end, function(result) - vim.schedule(function() - if result.success then - cache.set_credentials(platform, credentials) - logger.log( - display .. ' login successful', - { level = vim.log.levels.INFO, override = true } - ) - else - local err = result.error or 'unknown error' - cache.clear_credentials(platform) - logger.log( - display .. ' login failed: ' .. (constants.LOGIN_ERRORS[err] or err), - { level = vim.log.levels.ERROR } - ) - prompt_and_login(platform, display) - end - end) - end) - end) + end + ) end ---@param platform string? diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 0b203c8..5a661ec 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -20,23 +20,26 @@ local function prompt_credentials(platform, callback) return end local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - vim.ui.input({ prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, function(username) - if not username or username == '' then - logger.log('Submit cancelled', { level = vim.log.levels.WARN }) - return + vim.ui.input( + { prompt = '[cp.nvim]: ' .. display .. ' username ( to cancel): ' }, + function(username) + if not username or username == '' then + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) + return + end + vim.fn.inputsave() + local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') + vim.fn.inputrestore() + vim.cmd.redraw() + if not password or password == '' then + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) + return + end + local creds = { username = username, password = password } + cache.set_credentials(platform, creds) + callback(creds) end - vim.fn.inputsave() - local password = vim.fn.inputsecret('[cp.nvim]: ' .. display .. ' password: ') - vim.fn.inputrestore() - vim.cmd.redraw() - if not password or password == '' then - logger.log('Submit cancelled', { level = vim.log.levels.WARN }) - return - end - local creds = { username = username, password = password } - cache.set_credentials(platform, creds) - callback(creds) - end) + ) end ---@param opts { language?: string }?