Allow explicit browser search fill
ci / lint-test (pull_request) Successful in 6m13s
ci / build (pull_request) Successful in 6m8s

This commit is contained in:
Joe Julian
2026-04-28 21:15:15 -07:00
parent e171f49287
commit 72006aa4b1
5 changed files with 116 additions and 10 deletions
+42 -8
View File
@@ -700,7 +700,34 @@ async function statusForPage(options = {}) {
return refreshPageState(page.tabId, page.url, options); return refreshPageState(page.tabId, page.url, options);
} }
async function fillLogin(tabId, entryId) { function matchedLoginCredentialRequest(settings, entryId, pageUrl) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
};
}
function selectedLoginCredentialRequest(settings, entryId) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId
};
}
async function fillMatchedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, matchedLoginCredentialRequest);
}
async function fillSelectedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, selectedLoginCredentialRequest);
}
async function loginFillPage(tabId) {
if (!Number.isInteger(tabId)) { if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available."); throw new Error("No active tab is available.");
} }
@@ -709,7 +736,10 @@ async function fillLogin(tabId, entryId) {
if (!supportsPageStateURL(pageUrl)) { if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled."); throw new Error("This page cannot be filled.");
} }
return { url: pageUrl };
}
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
let state = await getPageState(tabId, pageUrl); let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, { state = await setPageState(tabId, {
...state, ...state,
@@ -729,12 +759,7 @@ async function fillLogin(tabId, entryId) {
throw new Error("API token is not configured."); throw new Error("API token is not configured.");
} }
const response = await connectNative({ const response = await connectNative(credentialRequest(settings, entryId, pageUrl));
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
if (!response?.success || !response.credential) { if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential."); throw new Error(response?.error || "KeePassGO did not return a credential.");
} }
@@ -846,6 +871,8 @@ const backgroundTestExports = {
shouldContinueWatchingState, shouldContinueWatchingState,
tokenPendingApprovalCount, tokenPendingApprovalCount,
savePlanForObservedLogin, savePlanForObservedLogin,
matchedLoginCredentialRequest,
selectedLoginCredentialRequest,
defaultSettings defaultSettings
}; };
@@ -872,7 +899,14 @@ if (isNodeTestEnv) {
focusTarget: cloneTarget(message.target) focusTarget: cloneTarget(message.target)
}); });
} }
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) }); sendResponse({ success: true, ...(await fillMatchedLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-fill-selected-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
sendResponse({ success: true, ...(await fillSelectedLogin(targetTabID, message.entryId)) });
return; return;
} }
case "keepassgo-load-settings": case "keepassgo-load-settings":
+21
View File
@@ -149,3 +149,24 @@ test("applyBestMatchOnly preserves all matches when disabled", () => {
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]); assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
}); });
test("matched login credential requests include the page URL for URL validation", () => {
assert.deepEqual(background.matchedLoginCredentialRequest({
bearerToken: "token-1"
}, "vault-console", "https://bellagio.example.invalid/login"), {
action: "get-login",
bearerToken: "token-1",
entryId: "vault-console",
url: "https://bellagio.example.invalid/login"
});
});
test("explicit selected credential requests omit the page URL", () => {
assert.deepEqual(background.selectedLoginCredentialRequest({
bearerToken: "token-1"
}, "no-url-entry"), {
action: "get-login",
bearerToken: "token-1",
entryId: "no-url-entry"
});
});
+15 -2
View File
@@ -97,7 +97,7 @@ function renderMatchList(root, matches, options = {}) {
setStatus("Filled", `${match.title} was sent to the current page.`, "ready"); setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
} }
} catch (error) { } catch (error) {
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error"); setStatus(options.errorTitle || (options.onSelect ? "Save failed" : "Fill failed"), error instanceof Error ? error.message : String(error), "error");
} finally { } finally {
row.disabled = false; row.disabled = false;
} }
@@ -147,7 +147,20 @@ function renderSearchResults(results, query) {
return; return;
} }
renderMatchList(root, results, { renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".` emptyMessage: `No entries matched "${query}".`,
errorTitle: "Fill failed",
onSelect: async (match, targetTabID) => {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({
type: "keepassgo-fill-selected-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
}); });
} }
+4
View File
@@ -394,6 +394,10 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page") return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
} }
} }
return s.browserCredential(ctx, token, entry)
}
func (s *Server) browserCredential(ctx context.Context, token apitokens.Token, entry vault.Entry) (*keepassgov1.GetBrowserCredentialResponse, error) {
if strings.TrimSpace(entry.Username) != "" { if strings.TrimSpace(entry.Username) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil { if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err return nil, err
+34
View File
@@ -693,6 +693,40 @@ func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
} }
} }
func TestVaultServiceGetsExplicitBrowserCredentialWithoutURLMatch(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "no-url-entry",
Title: "Livingston Console",
Username: "livingstondell",
Password: "demo-loop",
Path: []string{"Root", "Heist Crew"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
),
},
})
defer cleanup()
resp, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "no-url-entry",
})
if err != nil {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL) error = %v", err)
}
if resp.GetId() != "no-url-entry" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Id = %q, want no-url-entry", resp.GetId())
}
if resp.GetPassword() != "demo-loop" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Password = %q, want demo-loop", resp.GetPassword())
}
}
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) { func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
t.Parallel() t.Parallel()