Add browser extension gRPC bridge
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
NativeHostName = "org.keepassgo.browser"
|
||||
DefaultGRPCAddress = "127.0.0.1:47777"
|
||||
defaultFirefoxID = "browser@keepassgo.invalid"
|
||||
maxNativeMessageSize = 1024 * 1024
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
Matches []Match `json:"matches,omitempty"`
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Locked bool `json:"locked"`
|
||||
Dirty bool `json:"dirty,omitempty"`
|
||||
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
GRPCAddress string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||
}
|
||||
|
||||
type Browser string
|
||||
|
||||
const (
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserChromium Browser = "chromium"
|
||||
)
|
||||
|
||||
type NativeHostManifest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultFirefoxExtensionID() string {
|
||||
return defaultFirefoxID
|
||||
}
|
||||
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||
if size == 0 || size > maxNativeMessageSize {
|
||||
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||
}
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func WriteResponse(w io.Writer, resp Response) error {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode native response: %w", err)
|
||||
}
|
||||
if len(data) > maxNativeMessageSize {
|
||||
return fmt.Errorf("native response too large: %d", len(data))
|
||||
}
|
||||
var sizeBuf [4]byte
|
||||
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Request) Connection() (Connection, error) {
|
||||
conn := Connection{
|
||||
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
conn.GRPCAddress = DefaultGRPCAddress
|
||||
}
|
||||
if conn.BearerToken == "" {
|
||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||
conn, err := req.Connection()
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error()}
|
||||
}
|
||||
action := strings.TrimSpace(req.Action)
|
||||
switch action {
|
||||
case "status":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: "1"}
|
||||
case "find-logins":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
if status.Locked {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
|
||||
}
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
|
||||
case "get-login":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
if status.Locked {
|
||||
return Response{Success: false, Error: "vault is locked", Status: status}
|
||||
}
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
||||
default:
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectedStatus(addr string) *Status {
|
||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||
resp, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Status{
|
||||
Connected: true,
|
||||
Locked: resp.GetLocked(),
|
||||
Dirty: resp.GetDirty(),
|
||||
EntryCount: resp.GetEntryCount(),
|
||||
GRPCAddress: strings.TrimSpace(addr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Match, 0, len(resp))
|
||||
for _, match := range resp {
|
||||
out = append(out, Match{
|
||||
ID: match.GetId(),
|
||||
Title: match.GetTitle(),
|
||||
Username: match.GetUsername(),
|
||||
URL: match.GetUrl(),
|
||||
Path: append([]string(nil), match.GetPath()...),
|
||||
Quality: match.GetQuality(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||
id := strings.TrimSpace(entryID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("entry id is required")
|
||||
}
|
||||
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Credential{
|
||||
ID: resp.GetId(),
|
||||
Username: resp.GetUsername(),
|
||||
Password: resp.GetPassword(),
|
||||
URL: resp.GetUrl(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||
path := strings.TrimSpace(binaryPath)
|
||||
if path == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
id = defaultFirefoxID
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedExtensions: []string{id},
|
||||
}, nil
|
||||
case BrowserChrome, BrowserChromium:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||
}, nil
|
||||
default:
|
||||
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultManifestPath(browser Browser) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||
case BrowserChrome:
|
||||
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
case BrowserChromium:
|
||||
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||
manifest, err := Manifest(browser, binaryPath, extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" {
|
||||
path, err = DefaultManifestPath(browser)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
)
|
||||
|
||||
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var input bytes.Buffer
|
||||
body, err := json.Marshal(Request{
|
||||
Action: "find-logins",
|
||||
GRPCAddress: "127.0.0.1:47777",
|
||||
BearerToken: "secret",
|
||||
URL: "https://example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
if _, err := input.Write(body); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
req, err := ReadRequest(&input)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRequest() error = %v", err)
|
||||
}
|
||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||
t.Fatalf("WriteResponse() error = %v", err)
|
||||
}
|
||||
var size uint32
|
||||
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||
t.Fatalf("binary.Read() error = %v", err)
|
||||
}
|
||||
payload := make([]byte, size)
|
||||
if _, err := output.Read(payload); err != nil {
|
||||
t.Fatalf("Read() payload error = %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Version != "1" {
|
||||
t.Fatalf("response = %#v, want success version 1", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLogins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
||||
matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestGetLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: "vault-console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Url: "https://vault.example.invalid",
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "get-login",
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
||||
if resp.Success {
|
||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||
}
|
||||
|
||||
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("InstallManifest() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var manifest NativeHostManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if manifest.Path != binaryPath {
|
||||
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||
}
|
||||
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
status *keepassgov1.GetSessionStatusResponse
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.status == nil {
|
||||
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.matches, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.credential == nil {
|
||||
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||
}
|
||||
return f.credential, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type GRPCClient struct {
|
||||
client keepassgov1.VaultServiceClient
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = DefaultGRPCAddress
|
||||
}
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
address := strings.TrimSpace(conn.GRPCAddress)
|
||||
grpcConn, err := grpc.NewClient("passthrough:///"+address,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial("tcp", address)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", address, err)
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
|
||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||
}
|
||||
|
||||
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetMatches(), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: strings.TrimSpace(entryID),
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user