fix(ext/popup): normalize url field + humanize cryptic error messages

Bug: typing "Test" into an add-login form's URL field produced
"item json: relative URL without a base: "Test" at line 1 column 227"
in the UI banner — a serde-internal error message that no user should
ever see.

Two fixes:

1. Client-side URL normalization in the add/edit Login form
   (item-form.ts:normalizeUrl):
   - Empty string stays empty (URL is optional).
   - Scheme-less inputs get "https://" prepended so "github.com"
     becomes "https://github.com".
   - The result is run through the JS URL constructor. If that rejects
     OR if the result has no host, show a targeted message like
     "URL must include a host (e.g. https://example.com)".
   - Prevents the Rust-side url::Url::parse failure from ever firing
     for a form-shaped error.

2. Popup-side error humanizer (popup.ts:humanizeError):
   - Applied inside sendMessage so every UI-visible error passes
     through it before the state banner gets the string.
   - Translates: "relative URL without a base" → "URL must start with
     https://...", generic "item json:" / "settings json:" → form-
     field or corruption messages, and the sender/origin gates
     (vault_locked, origin_mismatch, unauthorized_sender,
     tab_navigated, captured_tab_gone) to user-action prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-22 19:45:55 -04:00
parent 357455d979
commit 9139dd78a0
2 changed files with 62 additions and 1 deletions

View File

@@ -163,11 +163,32 @@ function goBack(mode: 'add' | 'edit'): void {
}
}
/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it.
///
/// Prepends `https://` when the input looks like a bare host (no scheme),
/// then validates via the JS URL constructor. Returns { ok, value, error }.
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
if (!raw) return { ok: true, value: '' };
const trimmed = raw.trim();
// If it already has a scheme, pass through. Otherwise assume https://.
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const u = new URL(candidate);
// url::Url rejects schemes without an authority (host). Require a host.
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
return { ok: true, value: u.toString() };
} catch {
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
}
}
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const url = (document.getElementById('f-url') as HTMLInputElement).value.trim();
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
const password = (document.getElementById('f-password') as HTMLInputElement).value;
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
@@ -179,6 +200,13 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<v
return;
}
const urlResult = normalizeUrl(rawUrl);
if (!urlResult.ok) {
setState({ error: urlResult.error });
return;
}
const url = urlResult.value;
let totp: TotpConfig | undefined;
if (totpStr) {
try {

View File

@@ -68,11 +68,44 @@ export function setState(partial: Partial<PopupState>): void {
export function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
if (response && !response.ok && response.error) {
// Replace cryptic low-level errors with user-readable messages.
response = { ok: false, error: humanizeError(response.error) };
}
resolve(response);
});
});
}
/// Translate cryptic Rust/serde/WASM error strings into messages a user
/// can act on. Unknown errors pass through unchanged.
export function humanizeError(err: string): string {
// URL parse failures (Rust `url::Url::parse`) bubble up through serde
// as `item json: ...`. Match the core phrasing.
if (/relative URL without a base/i.test(err)) {
return 'URL must start with https:// or http:// (e.g. https://example.com)';
}
if (/item json:/i.test(err)) {
return 'Could not save item — one of the fields is in an invalid format.';
}
if (/settings json:/i.test(err)) {
return 'Settings are in an invalid format — try reloading the extension.';
}
if (/vault_locked/i.test(err)) {
return 'Vault is locked. Unlock and try again.';
}
if (/origin_mismatch/i.test(err)) {
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
}
if (/unauthorized_sender/i.test(err)) {
return 'This action is not allowed from here.';
}
if (/tab_navigated|captured_tab_gone/i.test(err)) {
return 'The browser tab changed before the fill could complete — try again.';
}
return err;
}
// --- Navigation ---
export function navigate(view: View, extras?: Partial<PopupState>): void {