Skip to content

Commit 0617d6b

Browse files
chrisdpurcellclaude
andcommitted
fix(keepass-cred-mgr): harden REPL quoting, output parsing, and notes escaping
Research into keepassxc-cli source revealed three additional corruption vectors: 1. REPL quoting model was wrong: the CLI uses Utils::splitCommandString (backslash escapes any character), not QProcess::splitCommand. Updated comments to document the actual behavior. 2. _parse_show_output misinterpreted notes containing "Password: ..." as field boundaries. Since Notes is always the last standard field in keepassxc-cli output, only Tags: terminates notes mode now. 3. keepassxc-cli's Add.cpp/Edit.cpp replace literal \\n in --notes values with actual newlines before storing. Added _escape_notes_for_cli() to prevent silent data corruption when notes contain backslash-n text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56944b3 commit 0617d6b

5 files changed

Lines changed: 76 additions & 27 deletions

File tree

plugins/keepass-cred-mgr/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1010
- Newlines in REPL arguments (notes, urls) corrupt the keepassxc-cli command stream, causing garbled entries and data loss. `_repl_quote()` now sanitizes `\n`/`\r` to spaces before quoting.
1111
- `deactivate_entry` constructed notes with an embedded newline (`\n[DEACTIVATED: ...]`) that split the REPL command across two lines. Uses ` | ` separator instead.
1212
- `run_cli` stdin_lines (passwords) now reject embedded newlines with a clear error instead of silently corrupting the stream.
13+
- Corrected REPL quoting model: the CLI uses `Utils::splitCommandString` (backslash escapes any character), not `QProcess::splitCommand`. Updated comments and docs.
14+
- `_parse_show_output` no longer misinterprets notes containing `Password: ...` or `URL: ...` text as field boundaries. Notes is the last standard field; only `Tags:` terminates it.
15+
- Notes passed via `--notes` are now escaped to prevent keepassxc-cli from silently converting literal `\n` text into newlines (the CLI replaces `\\n` → newline before storing).
1316

1417
## [0.5.1] - 2026-03-15
1518

plugins/keepass-cred-mgr/server/tools/read.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,20 @@
2323
type SearchResult = dict[str, str | None]
2424

2525

26-
# Known field prefixes that terminate a multi-line notes block.
27-
_KNOWN_FIELDS = {"username", "password", "url", "notes", "title", "tags"}
26+
# Fields that appear BEFORE notes in keepassxc-cli show output.
27+
# Once we enter notes mode, only "tags" (which appears after notes) terminates it.
28+
# This prevents notes containing "Password: ..." from being misinterpreted as fields.
29+
_PRE_NOTES_FIELDS = {"username", "password", "url", "title"}
2830

2931

3032
def _parse_show_output(stdout: str) -> EntryFields:
3133
"""Parse keepassxc-cli show output into a string field dict.
3234
33-
Notes can span multiple lines; continuation lines have no 'Key: ' prefix.
34-
Continuation stops when a line starts with a known field key followed by ': '.
35+
keepassxc-cli show outputs fields in order: Title, UserName, Password,
36+
URL, Notes, then Tags. Notes can span multiple lines with embedded
37+
newlines. Once we enter notes mode, only a Tags line terminates it;
38+
lines that look like "Password: foo" inside notes are continuation lines,
39+
not field boundaries.
3540
"""
3641
fields: dict[str, str] = {}
3742
in_notes = False
@@ -41,22 +46,24 @@ def _parse_show_output(stdout: str) -> EntryFields:
4146
if ": " in line:
4247
key, _, value = line.partition(": ")
4348
key_lower = key.strip().lower()
44-
if key_lower in _KNOWN_FIELDS:
45-
if in_notes:
49+
50+
# Inside notes, only "tags" can terminate — everything else is content
51+
if in_notes:
52+
if key_lower == "tags":
4653
fields["notes"] = "\n".join(notes_lines)
4754
in_notes = False
48-
if key_lower == "notes":
49-
notes_lines = [value.strip()]
50-
in_notes = True
51-
elif key_lower == "username":
52-
fields["username"] = value.strip()
53-
elif key_lower == "password":
54-
fields["password"] = value.strip()
55-
elif key_lower == "url":
56-
fields["url"] = value.strip()
57-
elif key_lower == "title":
58-
fields["title"] = value.strip()
55+
# Tags value is captured by _parse_tags separately; skip
56+
continue
57+
notes_lines.append(line)
5958
continue
59+
60+
if key_lower == "notes":
61+
notes_lines = [value.strip()]
62+
in_notes = True
63+
elif key_lower in _PRE_NOTES_FIELDS:
64+
fields[key_lower] = value.strip()
65+
continue
66+
6067
if in_notes:
6168
notes_lines.append(line)
6269

plugins/keepass-cred-mgr/server/tools/write.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@
3838
log: structlog.stdlib.BoundLogger = structlog.get_logger("keepass-cred-mgr.tools.write")
3939

4040

41+
def _escape_notes_for_cli(notes: str) -> str:
42+
"""Escape a notes string for --notes argument to keepassxc-cli.
43+
44+
The CLI's Add.cpp and Edit.cpp replace literal '\\n' with actual newlines
45+
before storing (entry->setNotes(notes.replace("\\\\n", "\\n"))). If the
46+
user's notes contain the two-character sequence '\\n', the CLI silently
47+
converts it to a newline. Escape it to '\\\\n' so the CLI round-trips
48+
to '\\n' as intended.
49+
"""
50+
return notes.replace("\\n", "\\\\n")
51+
52+
4153
@contextmanager
4254
def _write_lock(vault: Vault) -> Generator[FileLock]:
4355
"""Acquire and release a file lock around database writes."""
@@ -104,7 +116,7 @@ async def create_entry(
104116
if url is not None:
105117
cmd.extend(["--url", url])
106118
if notes is not None:
107-
cmd.extend(["--notes", notes])
119+
cmd.extend(["--notes", _escape_notes_for_cli(notes)])
108120
if password:
109121
# keepassxc-cli add has no --password flag; -p prompts stdin.
110122
# Write the password to stdin upfront so run_cli() doesn't deadlock.
@@ -152,7 +164,7 @@ async def deactivate_entry(
152164
# Update notes using new path — non-critical, log and continue on failure
153165
new_path = vault.entry_path(new_title, group)
154166
try:
155-
await vault.run_cli("edit", "--notes", new_notes, db, new_path)
167+
await vault.run_cli("edit", "--notes", _escape_notes_for_cli(new_notes), db, new_path)
156168
except KeePassCLIError:
157169
log.warning("deactivate_notes_update_failed", title=title, group=group)
158170

plugins/keepass-cred-mgr/server/vault.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,24 @@
3838
_REPL_PROMPT = b"\n> "
3939
_REPL_TIMEOUT = 30
4040

41-
# Characters that require double-quoting in the keepassxc-cli REPL.
42-
# The REPL uses Qt's QProcess::splitCommand (double-quote style),
43-
# NOT POSIX quoting — single quotes are NOT treated as delimiters.
41+
# The keepassxc-cli REPL uses Utils::splitCommandString (src/cli/Utils.cpp),
42+
# NOT Qt's QProcess::splitCommand. Key differences from POSIX:
43+
# - backslash escapes ANY next character (\x → x), not just inside quotes
44+
# - double quotes toggle quoting mode (only at start or after whitespace)
45+
# - single quotes have NO special meaning
46+
# Always double-quote arguments containing whitespace, quotes, or backslashes
47+
# to prevent the parser from consuming backslashes as escape characters.
4448
_REPL_NEEDS_QUOTING = frozenset(' \t"\\')
4549

4650

4751
def _repl_quote(arg: str) -> str:
48-
"""Quote one argument for keepassxc-cli REPL (double-quote style).
52+
"""Quote one argument for the keepassxc-cli REPL.
4953
50-
Qt's argument parser recognises double-quoted strings but not single-quoted
51-
strings. shlex.quote() prefers single quotes, which silently pass through
52-
the REPL as literal characters — breaking any argument that contains spaces.
54+
The REPL's parser (Utils::splitCommandString) treats backslash as an
55+
escape character for ANY following character. An unquoted backslash in
56+
a password or notes value silently drops the backslash and takes the
57+
next character literally. Double-quoting prevents this: inside quotes,
58+
only \\\\ and \\" are recognised as escapes.
5359
5460
Newlines and carriage returns are replaced with spaces before quoting.
5561
The REPL is line-based: a literal newline inside an argument splits the

plugins/keepass-cred-mgr/tests/unit/test_tools.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,31 @@ def test_multiline_notes_followed_by_field(self):
8989
def test_single_line_notes_unchanged(self):
9090
"""Existing single-line notes behavior preserved."""
9191
from server.tools.read import _parse_show_output
92-
output = "Title: Entry\nNotes: Single line\nUserName: admin\n"
92+
# Real keepassxc-cli output order: Title, UserName, Password, URL, Notes
93+
output = "Title: Entry\nUserName: admin\nNotes: Single line\n"
9394
result = _parse_show_output(output)
9495
assert result["notes"] == "Single line"
9596

97+
def test_notes_containing_field_like_text(self):
98+
"""Notes with 'Password: ...' inside are NOT misinterpreted as fields."""
99+
from server.tools.read import _parse_show_output
100+
output = (
101+
"Title: Entry\n"
102+
"UserName: admin\n"
103+
"Password: secret\n"
104+
"URL: https://example.com\n"
105+
"Notes: See credentials below\n"
106+
"Password: old-value-for-reference\n"
107+
"URL: https://internal.example.com\n"
108+
)
109+
result = _parse_show_output(output)
110+
assert result["password"] == "secret"
111+
assert result["notes"] == (
112+
"See credentials below\n"
113+
"Password: old-value-for-reference\n"
114+
"URL: https://internal.example.com"
115+
)
116+
96117

97118
# ---------------------------------------------------------------------------
98119
# _parse_tags

0 commit comments

Comments
 (0)