fix: prevent askpass password prompt from echoing plaintext#78
fix: prevent askpass password prompt from echoing plaintext#78
Conversation
Set options(askpass = ...) during R initialization with a custom handler that reads passwords directly from /dev/tty with echo disabled, bypassing reedline's stdin. reedline echoes input independently of terminal stty settings, so askpass::readline_silent()'s stty -echo had no effect. The handler is Unix-only and respects user-configured options(askpass). This follows the same approach used by radian. Closes #77 Co-Authored-By: Claude Opus 4.6 <[email protected]>
…dler
- Display prompt via stdout before stty -echo to stop the spinner
immediately (system() fork+exec is slow, spinner was running until then)
- Use stdout instead of stderr to avoid red error formatting on prompt
- Wrap file("/dev/tty") in suppressWarnings() to suppress the
"not a regular file" warning
Co-Authored-By: Claude Opus 4.6 <[email protected]>
cat() to stdout does not reliably trigger r_write_console_ex in embedded R, so the spinner keeps running. Switch back to stderr which reliably triggers r_write_console_ex and stops the spinner. To avoid the red error formatting that arf applies to stderr output, prepend ANSI reset (\033[0m) to cancel the red color immediately. The prompt is now displayed BEFORE stty -echo (not after), so the spinner stops as soon as the prompt appears. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Previous approach used R's cat() to display prompts and readLines() to read passwords from /dev/tty. This failed because R's cat() bypasses the WriteConsoleEx callback in embedded R, so the spinner was never stopped. New approach: - R side: sets ARF_ASKPASS_MODE env var, then calls readline(msg) - readline() triggers R's ReadConsole callback (r_read_console) - r_read_console calls stop_spinner() at the top (spinner stops!) - r_read_console detects ARF_ASKPASS_MODE env var - Instead of calling reedline callback, reads from /dev/tty directly - Echo disabled via termios (no subprocess fork like stty) - Password returned to R via ReadConsole buffer This is analogous to radian's approach (Python getpass.getpass reads from /dev/tty with echo disabled, bypassing prompt_toolkit). Co-Authored-By: Claude Opus 4.6 <[email protected]>
…_tty Clear the environment variable at the start of read_password_from_tty rather than relying solely on R's on.exit() cleanup. This prevents the env var from leaking if the function returns early or encounters an error, which would cause all subsequent readline() calls to be intercepted as password prompts. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Fixes plaintext password echoing when askpass::askpass() is invoked inside arf by bypassing reedline during password entry and reading directly from /dev/tty with terminal echo disabled.
Changes:
- Add a Unix-only
/dev/ttypassword read path inr_read_consolegated byARF_ASKPASS_MODE. - Add an R startup snippet to install an
options(askpass=...)handler that triggers the Rust bypass. - Extend PTY integration tests/utilities to assert that password input is not echoed.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| crates/arf-libr/src/sys.rs | Adds tty-based password read and exposes R code for askpass handler registration. |
| crates/arf-console/src/repl/mod.rs | Evaluates askpass handler setup code during REPL startup. |
| crates/arf-console/tests/pty_interactive_tests.rs | Strengthens askpass PTY test to ensure secret is not echoed. |
| crates/arf-console/tests/common/mod.rs | Adds Terminal::get_output() for asserting against captured PTY output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Write prompt and newline to /dev/tty instead of stdout, so the password interaction works correctly even when stdout is redirected - Use RAII TermiosGuard to guarantee terminal echo is restored on any exit path (including early returns and errors) - Treat termios failures as hard errors (abort password read) and log error details via std::io::Error::last_os_error() - Guard R handler with .Platform$OS.type check before registering options(askpass=...) to avoid overriding Windows GUI dialogs - Gate Rust-side initialization with #[cfg(unix)] Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rlong passwords - In r_read_console, fall through to normal input path when read_password_from_tty fails instead of returning 0 (EOF) which would terminate R's mainloop. - Reject passwords exceeding R's buffer capacity rather than silently truncating, since PENDING_INPUT is not applicable for single-shot password reads. Co-Authored-By: Claude Opus 4.6 <[email protected]>
When a password exceeds R's buffer capacity, return an empty line
("\n\0") with rc=1 instead of rc=0. This makes the R askpass handler
treat it as cancellation (nchar == 0 → NULL) rather than triggering
the reedline fallback path which would echo input in plaintext.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Guard against buflen < 2 at the top of read_password_from_tty to prevent out-of-bounds writes when writing "\n\0" in error/empty password paths. In practice R's buffer is always large, but this makes the safety invariant explicit. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Clear ECHONL along with ECHO in termios to prevent newline echo on platforms where ECHONL is set independently. - Check ARF_ASKPASS_MODE == "1" instead of just is_ok() to avoid interfering with unrelated env vars. - On read_password_from_tty failure, return empty password (fail closed) instead of falling back to reedline which would echo input in plaintext. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Use TCSAFLUSH instead of TCSANOW when disabling echo for password input. This discards any pending (type-ahead) input before applying the termios change, preventing stray characters from leaking into the password buffer. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove std::env::remove_var call to avoid thread-safety issues; let R's on.exit(Sys.unsetenv(...)) handle cleanup instead. - Add buf null check at the top of read_password_from_tty. - Handle buflen <= 1 in the fail-closed path by writing just a NUL terminator when there's no room for "\n\0". - Write prompt as raw bytes (CStr::to_bytes) instead of requiring UTF-8, preserving prompts in non-UTF8 locales. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ol flow
- Replace BufRead::read_line (UTF-8 only) with read_until(b'\n') into
Vec<u8> so passwords containing non-UTF8 bytes don't cause
InvalidData errors
- Refactor ASKPASS_HANDLER_CODE to use if/else instead of return()
inside local({}) block, which may error at top-level evaluation
Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Add askpass to r-deps/DESCRIPTION so the package is available in CI - Remove #[ignore] from test_pty_askpass so it runs with cargo test - Move prompt display AFTER tcsetattr(TCSAFLUSH) so the prompt appearance guarantees the terminal is ready to accept input - Use paste0() in test to construct prompt, preventing the test's expect() from matching the command echo instead of the real prompt Co-Authored-By: Claude Opus 4.6 <[email protected]>
The else branch wrote to buf[0] when buflen < 2, which is UB when buflen == 0. This was dead code (callers guarantee buflen >= 2) but violated the function's own safety contract. Remove the branch and strengthen the debug_assert to check both non-null and buflen >= 2. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Use write_volatile to overwrite the Vec<u8> holding the password, preventing the compiler from eliding the zeroing. Applied on both the success path and the overlong-rejection path. R itself keeps the value in a CHARSXP, so this is defense-in-depth rather than a full guarantee. Co-Authored-By: Claude Opus 4.6 <[email protected]>
If R's SIGINT handler longjmps out of read_password_from_tty, the TermiosGuard destructor is skipped and the terminal is left with echo disabled. Add a global PENDING_TERMIOS_RESTORE that records (fd, old_termios) before disabling echo. TermiosGuard::drop clears it on success. r_read_console checks it on every entry and recovers if a stale entry is found, restoring echo and closing the leaked fd. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add a comment explaining the SIGINT → EINTR → error → TermiosGuard → cancellation flow, and how the PENDING_TERMIOS_RESTORE safety net covers the longjmp case. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add has_askpass() helper (same pattern as has_dplyr()) and early-return in test_pty_askpass when the package is unavailable. Prevents unexpected hard failures in local environments without the package. Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Handle poisoned mutex in recover_pending_termios with into_inner() to ensure terminal recovery even in edge cases (233 #1) - Add comment explaining double-close safety: longjmp skips File destructor so fd is still open and cannot be reused (233 #2) - Add comment explaining the intentional if guard in write_empty_password as release-build safety net (231 #2) Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Replace the manual /dev/tty open, tcgetattr/tcsetattr, TermiosGuard RAII, BufReader::read_until loop, and newline echo with rpassword::prompt_password(). This removes ~170 lines of low-level termios code while preserving the longjmp safety net (PENDING_TERMIOS_RESTORE) which is arf-specific. Also removes zeroize_bytes() — neither radian nor the askpass R package performs password memory zeroization, and the threat model doesn't warrant it. Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Fix fd leak when PENDING_TERMIOS_RESTORE mutex is poisoned after successful tcgetattr (finding #2) - Restore comment explaining intentional if guard in write_empty_password for release builds where debug_assert is stripped (finding #3) - Document that rpassword's internal fd leaks on longjmp as a known minor limitation (finding #1) - Note UTF-8 restriction from rpassword vs previous raw bytes (finding #4) Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Handle poisoned mutex on normal return path using into_inner() to prevent snapshot fd leak (finding 1) - Log warning when /dev/tty snapshot fd cannot be opened (finding 2) - Zeroize password String via write_volatile before drop to minimize sensitive data exposure in memory (finding 3) Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Retry tcsetattr on EINTR in recover_pending_termios to avoid leaving the terminal without echo - Keep the termios snapshot on non-EINTR failure so the next r_read_console call can retry - Update doc comments to reference rpassword instead of the removed TermiosGuard Co-Authored-By: Claude Opus 4.6 <[email protected]>
When tcsetattr fails with a non-transient error, retrying on the next r_read_console call won't help. Clear the snapshot to avoid a permanent fd leak. Skip close on fatal errors since the fd may already be invalid. Co-Authored-By: Claude Opus 4.6 <[email protected]>
EBADF means the fd is already invalid, so close() is skipped. EIO/ENOTTY may leave a valid fd, so close() is still called to avoid a leak. Also restructured to eliminate clippy collapsible_if warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- In the defensive guard of read_password_from_tty, write a NUL terminator to buf (if non-null and buflen > 0) before returning, so R never reads stale/undefined buffer contents on FFI violation. - Add O_CLOEXEC to the /dev/tty open call so the snapshot fd is not inherited by child processes if longjmp leaks it. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
askpass::askpass()is called in arf, the password was displayed in plaintext because reedline echoes input independently of terminalsttysettings/dev/ttywith echo disabledrpasswordcrate, which handles/dev/ttyopen, termios echo suppression, reading, restoration, and newline echo internallyApproach
options(askpass = ...)handler at startup (Unix only) that callsreadline(paste0("\001ASKPASS\002", msg))— prepending a magic prefix (SOH + "ASKPASS" + STX) to the prompt. Skips if askpass option is already set.r_read_consoledetects the\x01ASKPASS\x02prefix in the prompt, bypasses reedline, and delegates torpassword::prompt_password()for the actual password reading with echo disabled.readline()triggers R'sReadConsolecallback which callsstop_spinner()— solving both the echo and spinner problems.Key design decisions
\x01ASKPASS\x02(SOH/STX control characters) to the readline prompt. The Rust ReadConsole callback detects this prefix to switch into password-reading mode. This is thread-safe and avoids env var mutation.rpassword::prompt_password()instead of hand-writing/dev/ttyopen,tcgetattr/tcsetattr, RAII guard, andread_untilloop. This is the same fundamental approach as radian (Python'sgetpass.getpass()).longjmppast rpassword's internal RAII, leaving echo disabled. We snapshot(fd, termios)before calling rpassword and recover inr_read_consoleon the next invocation viaPENDING_TERMIOS_RESTORE.Changes
Cargo.toml,crates/arf-libr/Cargo.tomlrpassworddependency (Unix only)crates/arf-libr/src/sys.rsread_password_from_tty()using rpassword, askpass detection inr_read_console, longjmp safety net (PENDING_TERMIOS_RESTORE), andASKPASS_HANDLER_CODER snippetcrates/arf-libr/src/lib.rsaskpass_handler_codewith#[cfg(unix)], replace wildcard re-exports with explicit itemscrates/arf-console/src/repl/mod.rs#[cfg(unix)])crates/arf-console/tests/pty_interactive_tests.rstest_pty_askpass— verify password is not echoed, skip if askpass package not installedcrates/arf-console/tests/common/mod.rsTerminal::get_output()andhas_askpass()helpercrates/arf-console/tests/r-deps/DESCRIPTIONaskpasspackage dependency for CITest plan
askpass::askpass(...)— prompt shown, input NOT echoed, value returnedreadline()calls unaffectedcargo test -p arf-console --test pty_interactive_tests)cargo clippy --workspace— no warnings🤖 Generated with Claude Code