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);
}
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)) {
throw new Error("No active tab is available.");
}
@@ -709,7 +736,10 @@ async function fillLogin(tabId, entryId) {
if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled.");
}
return { url: pageUrl };
}
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, {
...state,
@@ -729,12 +759,7 @@ async function fillLogin(tabId, entryId) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
const response = await connectNative(credentialRequest(settings, entryId, pageUrl));
if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential.");
}
@@ -846,6 +871,8 @@ const backgroundTestExports = {
shouldContinueWatchingState,
tokenPendingApprovalCount,
savePlanForObservedLogin,
matchedLoginCredentialRequest,
selectedLoginCredentialRequest,
defaultSettings
};
@@ -872,7 +899,14 @@ if (isNodeTestEnv) {
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;
}
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"]);
});
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");
}
} 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 {
row.disabled = false;
}
@@ -147,7 +147,20 @@ function renderSearchResults(results, query) {
return;
}
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 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 _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
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) {
t.Parallel()