Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open Leo from search #26082

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open

Open Leo from search #26082

wants to merge 5 commits into from

Conversation

yrliou
Copy link
Member

@yrliou yrliou commented Oct 17, 2024

Resolves brave/brave-browser#41711

open_leo_from_search_480p.mov
  • Add a new permission for opening Leo from Brave Search
  • Add a navigation throttle to intercept the request for opening Leo from Brave Search (specifically, https://search.brave.com/leo#noncevalue). If the request is from Brave Search (last committed URL) and nonce is valid (identical between the one in URL and the one in nonce property), a permission prompt will be shown by default and Leo side panel would be opened with entries from Brave Search conversations staged in Leo side panel.
  • A side change which removes the limitation of only have staged entries at the beginning of the conversation to accommodate that user could choose to click continue with Leo button in search UI multiple times and not only when there's empty Leo chat history. When need to remove staged entries, for example, when page context is unlinked, all staged entries will be cleared instead of whole chat history.

See Requirements #2 and #3 in https://docs.google.com/document/d/1idelFPpUEcKDNcyKYf3M5yw91tuIddjrlYfpaWEJjNk/edit?tab=t.0 for reference.

TODO: Open a security review for this PR.

Submitter Checklist:

  • I confirm that no security/privacy review is needed and no other type of reviews are needed, or that I have requested them
  • There is a ticket for my issue
  • Used Github auto-closing keywords in the PR description above
  • Wrote a good PR/commit description
  • Squashed any review feedback or "fixup" commits before merge, so that history is a record of what happened in the repo, not your PR
  • Added appropriate labels (QA/Yes or QA/No; release-notes/include or release-notes/exclude; OS/...) to the associated issue
  • Checked the PR locally:
    • npm run test -- brave_browser_tests, npm run test -- brave_unit_tests wiki
    • npm run presubmit wiki, npm run gn_check, npm run tslint
  • Ran git rebase master (if needed)

Reviewer Checklist:

  • A security review is not needed, or a link to one is included in the PR description
  • New files have MPL-2.0 license header
  • Adequate test coverage exists to prevent regressions
  • Major classes, functions and non-trivial code blocks are well-commented
  • Changes in component dependencies are properly reflected in gn
  • Code follows the style guide
  • Test plan is specified in PR before merging

After-merge Checklist:

Test Plan:

  1. Go to search.brave.com with experimental flags enabled.
  2. Type a query and click Answer with AI
  3. Type a follow-up question in search conversation mode UI
  4. Once answer is generated, click continue with Leo
  5. Check permission prompt is shown, and Leo side panel should be opened when permission is allowed, entries from search.brave.com should be staged in Leo.
  6. Visit brave://settings/content -> Additional permissions -> Leo AI chat, search.brave.com should be in the allow list.
  7. Remove the allow entry in settings UI
  8. Open a new tab and repeat step 1 to 4.
  9. Permission prompt should be shown again, reject the prompt, Leo panel should not be opened.

@yrliou yrliou self-assigned this Oct 17, 2024
@github-actions github-actions bot added CI/storybook-url Deploy storybook and provide a unique URL for each build CI/run-upstream-tests Run upstream unit and browser tests on Linux and Windows (otherwise only on Linux) labels Oct 17, 2024
kOsDesktop | kOsAndroid, \
FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \
})
#define BRAVE_AI_CHAT_FEATURE_ENTRIES \
Copy link
Member Author

@yrliou yrliou Oct 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is changed because BRAVE_AI_CHAT would cause some naming collision.
It also seems better grouping these together rather than separate defines anyway.

@yrliou yrliou force-pushed the open_leo_from_search branch 3 times, most recently from dce4a3c to 1f2c4c1 Compare October 19, 2024 00:13
@brave-builds
Copy link
Collaborator

A Storybook has been deployed to preview UI for the latest push

@yrliou yrliou force-pushed the open_leo_from_search branch 5 times, most recently from c93e8bb to 0c99be2 Compare October 19, 2024 03:25
@yrliou yrliou marked this pull request as ready for review October 21, 2024 17:17
@yrliou yrliou requested review from a team as code owners October 21, 2024 17:17
Copy link
Collaborator

@cdesouza-chromium cdesouza-chromium left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving some comments for now. Be back later.

Comment on lines +715 to +724
<!-- AI chat setting -->
<message name="IDS_SETTINGS_SITE_SETTINGS_BRAVE_AI_CHAT" desc="Label for Leo AI chat site settings.">
Leo AI chat
</message>
<message name="IDS_SETTINGS_SITE_SETTINGS_BRAVE_AI_CHAT_ASK" desc="Label for Leo AI chat site settings.">
Sites can ask to open Leo AI chat
</message>
<message name="IDS_SETTINGS_SITE_SETTINGS_BRAVE_AI_CHAT_BLOCK" desc="Label for Leo AI chat site settings.">
Don't allow site to open Leo AI chat
</message>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three entries are using "Label for Leo AI chat site settings." Can it be a bit more specific so the person doing the translation has a better idea what the distinction between these texts are,

Comment on lines +93 to +104
std::string script = R"(
var link = document.getElementById('continue-with-leo')
var url = new URL(link.href)
url.port = '$1'
link.href = url.href
link.click()
)";
std::string port = base::NumberToString(https_server_.port());

ASSERT_TRUE(content::ExecJs(
GetActiveWebContents()->GetPrimaryMainFrame(),
base::ReplaceStringPlaceholders(script, {port}, nullptr)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could be just:

ASSERT_TRUE(content::ExecJs(
  GetActiveWebContents()->GetPrimaryMainFrame(), content::JsReplace(R"(
      var link = document.getElementById('continue-with-leo')
      var url = new URL(link.href)
      url.port = '$1'
      link.href = url.href
      link.click()
    )", https_server_.port()
)));

NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonValidPath,
cur_prompt_count);
ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled(FROM_HERE,
++cur_prompt_count, true);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would help annotate this boolean in these call sites, like:

/*expected_leo_opened=*/true

Comment on lines +136 to +138
void ValidateOpenLeoButtonNonce(const base::Location& location,
const std::string& nonce,
bool expected_is_valid) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would help readability if we had something like this if we dropped the expected_is_valid and just returned a bool, like:

  bool ValidateOpenLeoButtonNonce(const base::Location& location,
                                  const std::string& nonce)

So rather than having:

ValidateOpenLeoButtonNonce(FROM_HERE, "5566", false);

We would have:

EXPECT_FALSE(ValidateOpenLeoButtonNonce(FROM_HERE, "5566"));

ValidateOpenLeoButtonNonce(FROM_HERE, "", false);

// Test invalid cases.
std::vector<std::string> invalid_cases = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const auto invalid_cases = std::to_array({ ... }));

SCOPED_TRACE(testing::Message() << "Invalid case: " << invalid_case);
NavigateURL(url);
ASSERT_TRUE(content::ExecJs(GetActiveWebContents()->GetPrimaryMainFrame(),
base::ReplaceStringPlaceholders(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content::JsReplace ditto

Comment on lines +26 to +29
AIChatBraveSearchThrottleDelegateImpl(
const AIChatBraveSearchThrottleDelegateImpl&) = delete;
AIChatBraveSearchThrottleDelegateImpl& operator=(
const AIChatBraveSearchThrottleDelegateImpl&) = delete;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could remove these constructors, as this class has no data, so there are no concerns in this class about copy.

@@ -276,4 +277,24 @@ SidebarService::ShowSidebarOption GetDefaultShowSidebarOption(
return ShowSidebarOption::kShowNever;
}

void ActivatePanelItem(content::WebContents* web_contents,
SidebarItem::BuiltInItemType panel_item) {
if (!web_contents) {
Copy link
Collaborator

@cdesouza-chromium cdesouza-chromium Oct 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, we should avoid unreachable branches. We should have a semantic understanding of each path of control flow. How is this reached? If we don't know, then we should not have a conditional. It may even be worth considering passing WebContents&. However, if we do know why this branch is needed, there should be a comment.

Copy link
Contributor

[puLL-Merge] - brave/brave-core@26082

Description

This PR implements a new feature for handling Leo AI chat requests from Brave Search. It adds a new permission type for AI chat, updates the UI to include settings for this permission, and implements a throttle mechanism to handle Leo AI chat requests from Brave Search.

Changes

Changes

  1. app/brave_settings_strings.grdp:

    • Added new string resources for Leo AI chat settings.
  2. browser/about_flags.cc:

    • Refactored AI chat feature entries for better organization.
  3. browser/ai_chat/BUILD.gn:

    • Added new test files for AI chat functionality.
  4. browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc:

    • Added new browser tests for AI chat Brave Search throttle functionality.
  5. browser/ai_chat/brave_ai_chat_permission_context_unittest.cc:

    • Added unit tests for the new AI chat permission context.
  6. browser/ai_chat/page_content_fetcher_browsertest.cc:

    • Added tests for validating the Open Leo button nonce.
  7. browser/brave_content_browser_client.cc:

    • Integrated the new AI chat Brave Search throttle into the navigation process.
  8. browser/resources/settings/:

    • Updated various TypeScript files to include AI chat settings in the UI.
  9. browser/ui/ai_chat/:

    • Implemented AI chat Brave Search throttle delegate.
  10. components/ai_chat/:

    • Added new files and modified existing ones to implement AI chat Brave Search throttle and related functionality.
  11. components/permissions/:

    • Added a new permission context for AI chat and updated related files.
  12. ios/browser/flags/about_flags.mm:

    • Updated AI chat feature entries for iOS.
  13. test/data/leo/:

    • Added test HTML files for Leo AI chat functionality.

Possible Issues

  • The implementation assumes that the AI chat feature is enabled. There might be edge cases where this assumption doesn't hold true.
  • The throttle mechanism might introduce a slight delay in navigation for non-AI chat related requests from Brave Search.

Security Hotspots

  1. components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc:

    • The nonce validation logic is critical for ensuring that only legitimate Leo AI chat requests are processed. Any weakness in this validation could potentially be exploited.
  2. components/permissions/contexts/brave_ai_chat_permission_context.cc:

    • The permission context restricts AI chat permissions to secure origins and specifically to search.brave.com. Ensure that this restriction is not bypassed in any way.
  3. browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.cc:

    • The OpenLeo function directly interacts with the UI to open the Leo panel. Ensure that this can't be abused to open the panel in unintended scenarios.

base::BindOnce(on_script_executed, nonce, std::move(callback)),
blink::BackForwardCacheAware::kAllow,
blink::mojom::WantResultOption::kWantResult,
blink::mojom::PromiseResultOption::kAwait);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return;
}
content_extractor_.set_disconnect_handler(base::BindOnce(
&PageContentFetcherInternal::DeleteSelf, base::Unretained(this)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reported by reviewdog 🐶
[semgrep] base::Unretained is most of the time unrequited, and a weak reference is better suited for secure coding.
Consider swapping Unretained for a weak reference.
base::Unretained usage may be acceptable when a callback owner is guaranteed
to be destroyed with the object base::Unretained is pointing to, for example:

- PrefChangeRegistrar
- base::*Timer
- mojo::Receiver
- any other class member destroyed when the class is deallocated


Source: https://github.com/brave/security-action/blob/main/assets/semgrep_rules/client/chromium-uaf.yaml


Cc @thypon @goodov @iefremov

Copy link
Contributor

@fallaciousreasoning fallaciousreasoning left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @yrliou, I've started taking a look at this - I've left a few comments on the spec but in general its looking pretty good.

  1. How will Brave search know if Brave supports this API? They can't just show it for all Brave browsers. It shouldn't show for:
  • Old versions of Brave
  • Versions of Brave with the feature disabled
  • Users who have denied the permission
  1. It feels slightly weird this permission will show for all sites (in site settings) when it can only apply to Brave Search
  2. I'm a bit confused about what the point of checking the nonce matches whats in the url is? Presumably if someone can change one of them they can change both. I'm just struggling to imagine what the attack scenario would be?
  3. More nit-pickily, I wonder if data-nonce would be a better attribute? https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes
  4. The spec doesn't seem to specify an id for the a tag (maybe just needs an update)

@@ -75,6 +75,10 @@ export default function addBraveRoutes(r: Partial<SettingsRoutes>) {
r.SITE_SETTINGS_LOCALHOST_ACCESS = r.SITE_SETTINGS
.createChild('localhostAccess')
}
const isBraveAIChatFeatureEnabled = loadTimeData.getBoolean('isBraveAIChatFeatureEnabled')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it feels slightly odd to me that this is going to live in Site Settings - the only site this really applies to is Brave Search, right?

Comment on lines +332 to +333
auto element = render_frame()->GetWebFrame()->GetDocument().GetElementById(
"continue-with-leo");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey this continue-with-leo id doesn't seem to mentioned in the spec

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added


auto element = render_frame()->GetWebFrame()->GetDocument().GetElementById(
"continue-with-leo");
if (element.IsNull() || !element.HasHTMLTagName("a")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a call to QuerySelector would make more sense instead of all this?

render_frame()->GetWebFrame()->GetDocument()->QuerySelector("a#continue-with-leo[href][nonce]")

and then you wouldn't need to check

  1. The tagName
  2. Whether there's a href
  3. Whether there's a nonce

return;
}

GURL url(element.GetAttribute("href").Utf8());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably check whether there is a href first - it looks like GetAttribute will return WTF::g_null_atom when the attribute isn't present and I don't know how that will interact with GURL

(not necessary if we use QuerySelector as above)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's covered in test case, it would be empty, hence an invalid URL.
This function correctly runs callback with false without any unexpected behavior or crashes.

}

GURL url(element.GetAttribute("href").Utf8());
if (!IsOpenLeoButtonFromBraveSearchURL(url) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional as there would be some StrCat, but this IsOpenLeoButtonFromBraveSearchURL could be made into part of the QuerySelector using a startsWith attribute selector:

[href^="https://search.brave.com/leo#"]

additionally, this check doesn't test whether we have the #$nonce part of the url, just whether the /leo part of the path is present - probably isn't too important though

// to execute a script to get it.
blink::WebScriptSource source =
blink::WebScriptSource(blink::WebString::FromUTF8(
"document.getElementById('continue-with-leo').nonce"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I wonder why that is - does it work with a data-* attribute (i.e. data-nonce)?

I think this would be more idiomatic anyway, as data-* attributes are namespaced for custom attributes

}

bool IsOpenLeoButtonFromBraveSearchURL(const GURL& url) {
return IsBraveSearchURL(url) && url.path_piece() == "/leo";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also check:

  1. There is a fragment
  2. The fragment is non-empty?

as I understand it from the spec, the OpenLeo url is invalid unless it has a nonce, and the nonce matches whats in the url

// Check if origin is https://search.brave.com.
GURL& origin = request_data.requesting_origin;
if (origin.SchemeIs(url::kHttpsScheme) &&
origin.host_piece() == brave_domains::GetServicesDomain("search")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
origin.host_piece() == brave_domains::GetServicesDomain("search")) {
origin.host_piece() == brave_domains::GetServicesDomain(kBraveSearchURLPrefix)) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CI/run-upstream-tests Run upstream unit and browser tests on Linux and Windows (otherwise only on Linux) CI/storybook-url Deploy storybook and provide a unique URL for each build needs-security-review puLL-Merge
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[SERP->Leo] Support Continue with Leo button from Brave Search
9 participants