Skip to content

fix: prevent askpass password prompt from echoing plaintext#78

Merged
eitsupi merged 38 commits intomainfrom
fix/askpass-plaintext-echo
Feb 11, 2026
Merged

fix: prevent askpass password prompt from echoing plaintext#78
eitsupi merged 38 commits intomainfrom
fix/askpass-plaintext-echo

Conversation

@eitsupi
Copy link
Copy Markdown
Owner

@eitsupi eitsupi commented Feb 10, 2026

Summary

  • Fix Doesn't accept system() inputs #77: When askpass::askpass() is called in arf, the password was displayed in plaintext because reedline echoes input independently of terminal stty settings
  • Implement a two-layer approach (R + Rust) to bypass reedline and read passwords directly from /dev/tty with echo disabled
  • Password reading is delegated to the rpassword crate, which handles /dev/tty open, termios echo suppression, reading, restoration, and newline echo internally

Approach

  1. R side: Register options(askpass = ...) handler at startup (Unix only) that calls readline(paste0("\001ASKPASS\002", msg)) — prepending a magic prefix (SOH + "ASKPASS" + STX) to the prompt. Skips if askpass option is already set.
  2. Rust side: r_read_console detects the \x01ASKPASS\x02 prefix in the prompt, bypasses reedline, and delegates to rpassword::prompt_password() for the actual password reading with echo disabled.
  3. readline() triggers R's ReadConsole callback which calls stop_spinner() — solving both the echo and spinner problems.

Key design decisions

  • Prompt-prefix signaling: The R handler prepends \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 crate: Delegates termios manipulation to rpassword::prompt_password() instead of hand-writing /dev/tty open, tcgetattr/tcsetattr, RAII guard, and read_until loop. This is the same fundamental approach as radian (Python's getpass.getpass()).
  • longjmp safety net: R's SIGINT handler can longjmp past rpassword's internal RAII, leaving echo disabled. We snapshot (fd, termios) before calling rpassword and recover in r_read_console on the next invocation via PENDING_TERMIOS_RESTORE.
  • Fail closed: If rpassword or any setup fails, returns an empty password instead of falling back to reedline (which would echo plaintext).

Changes

File Description
Cargo.toml, crates/arf-libr/Cargo.toml Add rpassword dependency (Unix only)
crates/arf-libr/src/sys.rs Add read_password_from_tty() using rpassword, askpass detection in r_read_console, longjmp safety net (PENDING_TERMIOS_RESTORE), and ASKPASS_HANDLER_CODE R snippet
crates/arf-libr/src/lib.rs Re-export askpass_handler_code with #[cfg(unix)], replace wildcard re-exports with explicit items
crates/arf-console/src/repl/mod.rs Evaluate askpass handler R code during REPL startup (#[cfg(unix)])
crates/arf-console/tests/pty_interactive_tests.rs Strengthen test_pty_askpass — verify password is not echoed, skip if askpass package not installed
crates/arf-console/tests/common/mod.rs Add Terminal::get_output() and has_askpass() helper
crates/arf-console/tests/r-deps/DESCRIPTION Add askpass package dependency for CI

Test plan

  • askpass::askpass(...) — prompt shown, input NOT echoed, value returned
  • Spinner stops immediately when password prompt appears
  • Password not echoed in plaintext (verified via PTY output inspection)
  • Normal readline() calls unaffected
  • PTY tests pass (cargo test -p arf-console --test pty_interactive_tests)
  • cargo clippy --workspace — no warnings

🤖 Generated with Claude Code

eitsupi and others added 5 commits February 10, 2026 11:28
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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/tty password read path in r_read_console gated by ARF_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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

eitsupi and others added 3 commits February 10, 2026 13:19
…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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

eitsupi and others added 2 commits February 10, 2026 13:45
- 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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

eitsupi and others added 2 commits February 11, 2026 08:55
…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]>
eitsupi and others added 6 commits February 11, 2026 11:33
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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@eitsupi eitsupi marked this pull request as draft February 11, 2026 13:02
eitsupi and others added 2 commits February 11, 2026 13:08
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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@eitsupi eitsupi requested a review from Copilot February 11, 2026 13:25
@eitsupi eitsupi marked this pull request as ready for review February 11, 2026 13:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

eitsupi and others added 3 commits February 11, 2026 13:37
- 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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@eitsupi eitsupi merged commit ef14033 into main Feb 11, 2026
10 checks passed
@eitsupi eitsupi deleted the fix/askpass-plaintext-echo branch February 11, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Doesn't accept system() inputs

2 participants