diff --git a/docs/api/session.md b/docs/api/session.md index 5b1981d0da..80a6ec369b 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -690,6 +690,41 @@ The `proxyBypassRules` is a comma separated list of rules described below: Match local addresses. The meaning of `` is whether the host matches one of: "127.0.0.1", "::1", "localhost". +#### `ses.resolveHost(host, [options])` + +* `host` string - Hostname to resolve. +* `options` Object (optional) + * `queryType` string (optional) - Requested DNS query type. If unspecified, + resolver will pick A or AAAA (or both) based on IPv4/IPv6 settings: + * `A` - Fetch only A records + * `AAAA` - Fetch only AAAA records. + * `source` string (optional) - The source to use for resolved addresses. + Default allows the resolver to pick an appropriate source. Only affects use + of big external sources (e.g. calling the system for resolution or using + DNS). Even if a source is specified, results can still come from cache, + resolving "localhost" or IP literals, etc. One of the following values: + * `any` (default) - Resolver will pick an appropriate source. Results could + come from DNS, MulticastDNS, HOSTS file, etc + * `system` - Results will only be retrieved from the system or OS, e.g. via + the `getaddrinfo()` system call + * `dns` - Results will only come from DNS queries + * `mdns` - Results will only come from Multicast DNS queries + * `localOnly` - No external sources will be used. Results will only come + from fast local sources that are available no matter the source setting, + e.g. cache, hosts file, IP literal resolution, etc. + * `cacheUsage` string (optional) - Indicates what DNS cache entries, if any, + can be used to provide a response. One of the following values: + * `allowed` (default) - Results may come from the host cache if non-stale + * `staleAllowed` - Results may come from the host cache even if stale (by + expiration or network changes) + * `disallowed` - Results will not come from the host cache. + * `secureDnsPolicy` string (optional) - Controls the resolver's Secure DNS + behavior for this request. One of the following values: + * `allow` (default) + * `disable` + +Returns [`Promise`](structures/resolved-host.md) - Resolves with the resolved IP addresses for the `host`. + #### `ses.resolveProxy(url)` * `url` URL diff --git a/docs/api/structures/resolved-endpoint.md b/docs/api/structures/resolved-endpoint.md new file mode 100644 index 0000000000..0847429f04 --- /dev/null +++ b/docs/api/structures/resolved-endpoint.md @@ -0,0 +1,7 @@ +# ResolvedEndpoint Object + +* `address` string +* `family` string - One of the following: + * `ipv4` - Corresponds to `AF_INET` + * `ipv6` - Corresponds to `AF_INET6` + * `unspec` - Corresponds to `AF_UNSPEC` diff --git a/docs/api/structures/resolved-host.md b/docs/api/structures/resolved-host.md new file mode 100644 index 0000000000..43c577f1fb --- /dev/null +++ b/docs/api/structures/resolved-host.md @@ -0,0 +1,3 @@ +# ResolvedHost Object + +* `endpoints` [ResolvedEndpoint[]](resolved-endpoint.md) - resolved DNS entries for the hostname diff --git a/filenames.auto.gni b/filenames.auto.gni index c29eebcb20..7dd1edf0e8 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -114,6 +114,8 @@ auto_filenames = { "docs/api/structures/protocol-response.md", "docs/api/structures/rectangle.md", "docs/api/structures/referrer.md", + "docs/api/structures/resolved-endpoint.md", + "docs/api/structures/resolved-host.md", "docs/api/structures/scrubber-item.md", "docs/api/structures/segmented-control-segment.md", "docs/api/structures/serial-port.md", diff --git a/filenames.gni b/filenames.gni index b16fada9c2..ab3b7365c0 100644 --- a/filenames.gni +++ b/filenames.gni @@ -433,6 +433,8 @@ filenames = { "shell/browser/net/proxying_url_loader_factory.h", "shell/browser/net/proxying_websocket.cc", "shell/browser/net/proxying_websocket.h", + "shell/browser/net/resolve_host_function.cc", + "shell/browser/net/resolve_host_function.h", "shell/browser/net/resolve_proxy_helper.cc", "shell/browser/net/resolve_proxy_helper.h", "shell/browser/net/system_network_context_manager.cc", diff --git a/shell/browser/api/electron_api_session.cc b/shell/browser/api/electron_api_session.cc index b80978ee89..22ff6ab78c 100644 --- a/shell/browser/api/electron_api_session.cc +++ b/shell/browser/api/electron_api_session.cc @@ -65,6 +65,7 @@ #include "shell/browser/javascript_environment.h" #include "shell/browser/media/media_device_id_salt.h" #include "shell/browser/net/cert_verifier_client.h" +#include "shell/browser/net/resolve_host_function.h" #include "shell/browser/session_preferences.h" #include "shell/common/gin_converters/callback_converter.h" #include "shell/common/gin_converters/content_converter.h" @@ -426,6 +427,37 @@ v8::Local Session::ResolveProxy(gin::Arguments* args) { return handle; } +v8::Local Session::ResolveHost( + std::string host, + absl::optional params) { + gin_helper::Promise promise(isolate_); + v8::Local handle = promise.GetHandle(); + + auto fn = base::MakeRefCounted( + browser_context_, std::move(host), + params ? std::move(params.value()) : nullptr, + base::BindOnce( + [](gin_helper::Promise promise, + int64_t net_error, const absl::optional& addrs) { + if (net_error < 0) { + promise.RejectWithErrorMessage(net::ErrorToString(net_error)); + } else { + DCHECK(addrs.has_value() && !addrs->empty()); + + v8::HandleScope handle_scope(promise.isolate()); + gin_helper::Dictionary dict = + gin::Dictionary::CreateEmpty(promise.isolate()); + dict.Set("endpoints", addrs->endpoints()); + promise.Resolve(dict); + } + }, + std::move(promise))); + + fn->Run(); + + return handle; +} + v8::Local Session::GetCacheSize() { gin_helper::Promise promise(isolate_); auto handle = promise.GetHandle(); @@ -1242,6 +1274,7 @@ gin::Handle Session::New() { void Session::FillObjectTemplate(v8::Isolate* isolate, v8::Local templ) { gin::ObjectTemplateBuilder(isolate, "Session", templ) + .SetMethod("resolveHost", &Session::ResolveHost) .SetMethod("resolveProxy", &Session::ResolveProxy) .SetMethod("getCacheSize", &Session::GetCacheSize) .SetMethod("clearCache", &Session::ClearCache) diff --git a/shell/browser/api/electron_api_session.h b/shell/browser/api/electron_api_session.h index d80baaf6fb..248673a07a 100644 --- a/shell/browser/api/electron_api_session.h +++ b/shell/browser/api/electron_api_session.h @@ -13,6 +13,7 @@ #include "electron/buildflags/buildflags.h" #include "gin/handle.h" #include "gin/wrappable.h" +#include "services/network/public/mojom/host_resolver.mojom.h" #include "services/network/public/mojom/ssl_config.mojom.h" #include "shell/browser/event_emitter_mixin.h" #include "shell/browser/net/resolve_proxy_helper.h" @@ -96,6 +97,9 @@ class Session : public gin::Wrappable, const char* GetTypeName() override; // Methods. + v8::Local ResolveHost( + std::string host, + absl::optional params); v8::Local ResolveProxy(gin::Arguments* args); v8::Local GetCacheSize(); v8::Local ClearCache(); diff --git a/shell/browser/net/resolve_host_function.cc b/shell/browser/net/resolve_host_function.cc new file mode 100644 index 0000000000..f7ccf72183 --- /dev/null +++ b/shell/browser/net/resolve_host_function.cc @@ -0,0 +1,78 @@ +// Copyright (c) 2023 Signal Messenger, LLC +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/net/resolve_host_function.h" + +#include +#include + +#include "base/functional/bind.h" +#include "base/values.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/browser_thread.h" +#include "content/public/browser/storage_partition.h" +#include "net/base/host_port_pair.h" +#include "net/base/net_errors.h" +#include "net/base/network_isolation_key.h" +#include "net/dns/public/resolve_error_info.h" +#include "shell/browser/electron_browser_context.h" +#include "url/origin.h" + +using content::BrowserThread; + +namespace electron { + +ResolveHostFunction::ResolveHostFunction( + ElectronBrowserContext* browser_context, + std::string host, + network::mojom::ResolveHostParametersPtr params, + ResolveHostCallback callback) + : browser_context_(browser_context), + host_(std::move(host)), + params_(std::move(params)), + callback_(std::move(callback)) {} + +ResolveHostFunction::~ResolveHostFunction() { + DCHECK_CURRENTLY_ON(BrowserThread::UI); + DCHECK(!receiver_.is_bound()); +} + +void ResolveHostFunction::Run() { + DCHECK_CURRENTLY_ON(BrowserThread::UI); + DCHECK(!receiver_.is_bound()); + + // Start the request. + net::HostPortPair host_port_pair(host_, 0); + mojo::PendingRemote resolve_host_client = + receiver_.BindNewPipeAndPassRemote(); + receiver_.set_disconnect_handler(base::BindOnce( + &ResolveHostFunction::OnComplete, this, net::ERR_NAME_NOT_RESOLVED, + net::ResolveErrorInfo(net::ERR_FAILED), + /*resolved_addresses=*/absl::nullopt, + /*endpoint_results_with_metadata=*/absl::nullopt)); + browser_context_->GetDefaultStoragePartition() + ->GetNetworkContext() + ->ResolveHost(network::mojom::HostResolverHost::NewHostPortPair( + std::move(host_port_pair)), + net::NetworkAnonymizationKey(), std::move(params_), + std::move(resolve_host_client)); +} + +void ResolveHostFunction::OnComplete( + int result, + const net::ResolveErrorInfo& resolve_error_info, + const absl::optional& resolved_addresses, + const absl::optional& + endpoint_results_with_metadata) { + DCHECK_CURRENTLY_ON(BrowserThread::UI); + + // Ensure that we outlive the `receiver_.reset()` call. + scoped_refptr self(this); + + receiver_.reset(); + + std::move(callback_).Run(resolve_error_info.error, resolved_addresses); +} + +} // namespace electron diff --git a/shell/browser/net/resolve_host_function.h b/shell/browser/net/resolve_host_function.h new file mode 100644 index 0000000000..97203bd844 --- /dev/null +++ b/shell/browser/net/resolve_host_function.h @@ -0,0 +1,69 @@ +// Copyright (c) 2023 Signal Messenger, LLC +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_NET_RESOLVE_HOST_FUNCTION_H_ +#define ELECTRON_SHELL_BROWSER_NET_RESOLVE_HOST_FUNCTION_H_ + +#include +#include +#include + +#include "base/memory/ref_counted.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "net/base/address_list.h" +#include "net/dns/public/host_resolver_results.h" +#include "services/network/public/cpp/resolve_host_client_base.h" +#include "services/network/public/mojom/host_resolver.mojom.h" +#include "services/network/public/mojom/network_context.mojom.h" +#include "third_party/abseil-cpp/absl/types/optional.h" + +namespace electron { + +class ElectronBrowserContext; + +class ResolveHostFunction + : public base::RefCountedThreadSafe, + network::ResolveHostClientBase { + public: + using ResolveHostCallback = base::OnceCallback& resolved_addresses)>; + + explicit ResolveHostFunction(ElectronBrowserContext* browser_context, + std::string host, + network::mojom::ResolveHostParametersPtr params, + ResolveHostCallback callback); + + void Run(); + + // disable copy + ResolveHostFunction(const ResolveHostFunction&) = delete; + ResolveHostFunction& operator=(const ResolveHostFunction&) = delete; + + protected: + ~ResolveHostFunction() override; + + private: + friend class base::RefCountedThreadSafe; + + // network::mojom::ResolveHostClient implementation + void OnComplete(int result, + const net::ResolveErrorInfo& resolve_error_info, + const absl::optional& resolved_addresses, + const absl::optional& + endpoint_results_with_metadata) override; + + // Receiver for the currently in-progress request, if any. + mojo::Receiver receiver_{this}; + + // Weak Ref + ElectronBrowserContext* browser_context_; + std::string host_; + network::mojom::ResolveHostParametersPtr params_; + ResolveHostCallback callback_; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_NET_RESOLVE_HOST_FUNCTION_H_ diff --git a/shell/common/gin_converters/net_converter.cc b/shell/common/gin_converters/net_converter.cc index 4410fb5053..f7b6c37b08 100644 --- a/shell/common/gin_converters/net_converter.cc +++ b/shell/common/gin_converters/net_converter.cc @@ -663,4 +663,154 @@ v8::Local Converter::ToV8( return ConvertToV8(isolate, dict); } +// static +v8::Local Converter::ToV8( + v8::Isolate* isolate, + const net::IPEndPoint& val) { + gin::Dictionary dict(isolate, v8::Object::New(isolate)); + dict.Set("address", val.ToStringWithoutPort()); + switch (val.GetFamily()) { + case net::ADDRESS_FAMILY_IPV4: { + dict.Set("family", "ipv4"); + break; + } + case net::ADDRESS_FAMILY_IPV6: { + dict.Set("family", "ipv6"); + break; + } + case net::ADDRESS_FAMILY_UNSPECIFIED: { + dict.Set("family", "unspec"); + break; + } + } + return ConvertToV8(isolate, dict); +} + +// static +bool Converter::FromV8(v8::Isolate* isolate, + v8::Local val, + net::DnsQueryType* out) { + std::string query_type; + if (!ConvertFromV8(isolate, val, &query_type)) + return false; + + if (query_type == "A") { + *out = net::DnsQueryType::A; + return true; + } + + if (query_type == "AAAA") { + *out = net::DnsQueryType::AAAA; + return true; + } + + return false; +} + +// static +bool Converter::FromV8(v8::Isolate* isolate, + v8::Local val, + net::HostResolverSource* out) { + std::string query_type; + if (!ConvertFromV8(isolate, val, &query_type)) + return false; + + if (query_type == "any") { + *out = net::HostResolverSource::ANY; + return true; + } + + if (query_type == "system") { + *out = net::HostResolverSource::SYSTEM; + return true; + } + + if (query_type == "dns") { + *out = net::HostResolverSource::DNS; + return true; + } + + if (query_type == "mdns") { + *out = net::HostResolverSource::MULTICAST_DNS; + return true; + } + + if (query_type == "localOnly") { + *out = net::HostResolverSource::LOCAL_ONLY; + return true; + } + + return false; +} + +// static +bool Converter::FromV8( + v8::Isolate* isolate, + v8::Local val, + network::mojom::ResolveHostParameters::CacheUsage* out) { + std::string query_type; + if (!ConvertFromV8(isolate, val, &query_type)) + return false; + + if (query_type == "allowed") { + *out = network::mojom::ResolveHostParameters::CacheUsage::ALLOWED; + return true; + } + + if (query_type == "staleAllowed") { + *out = network::mojom::ResolveHostParameters::CacheUsage::STALE_ALLOWED; + return true; + } + + if (query_type == "disallowed") { + *out = network::mojom::ResolveHostParameters::CacheUsage::DISALLOWED; + return true; + } + + return false; +} + +// static +bool Converter::FromV8( + v8::Isolate* isolate, + v8::Local val, + network::mojom::SecureDnsPolicy* out) { + std::string query_type; + if (!ConvertFromV8(isolate, val, &query_type)) + return false; + + if (query_type == "allow") { + *out = network::mojom::SecureDnsPolicy::ALLOW; + return true; + } + + if (query_type == "disable") { + *out = network::mojom::SecureDnsPolicy::DISABLE; + return true; + } + + return false; +} + +// static +bool Converter::FromV8( + v8::Isolate* isolate, + v8::Local val, + network::mojom::ResolveHostParametersPtr* out) { + gin::Dictionary dict(nullptr); + if (!ConvertFromV8(isolate, val, &dict)) + return false; + + network::mojom::ResolveHostParametersPtr params = + network::mojom::ResolveHostParameters::New(); + + dict.Get("queryType", &(params->dns_query_type)); + dict.Get("source", &(params->source)); + dict.Get("cacheUsage", &(params->cache_usage)); + dict.Get("secureDnsPolicy", &(params->secure_dns_policy)); + + *out = std::move(params); + return true; +} + } // namespace gin diff --git a/shell/common/gin_converters/net_converter.h b/shell/common/gin_converters/net_converter.h old mode 100755 new mode 100644 index 620c501a65..a375090898 --- a/shell/common/gin_converters/net_converter.h +++ b/shell/common/gin_converters/net_converter.h @@ -11,6 +11,7 @@ #include "gin/converter.h" #include "services/network/public/mojom/fetch_api.mojom.h" +#include "services/network/public/mojom/host_resolver.mojom.h" #include "services/network/public/mojom/url_request.mojom.h" #include "shell/browser/net/cert_verifier_client.h" @@ -109,6 +110,47 @@ struct Converter { const net::RedirectInfo& val); }; +template <> +struct Converter { + static v8::Local ToV8(v8::Isolate* isolate, + const net::IPEndPoint& val); +}; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + net::DnsQueryType* out); +}; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + net::HostResolverSource* out); +}; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + network::mojom::ResolveHostParameters::CacheUsage* out); +}; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + network::mojom::SecureDnsPolicy* out); +}; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + network::mojom::ResolveHostParametersPtr* out); +}; + template struct Converter>> { static bool FromV8(v8::Isolate* isolate, diff --git a/spec/api-session-spec.ts b/spec/api-session-spec.ts index 5fbb9cb817..0e6901d999 100644 --- a/spec/api-session-spec.ts +++ b/spec/api-session-spec.ts @@ -487,6 +487,53 @@ describe('session module', () => { }); }); + describe('ses.resolveHost(host)', () => { + let customSession: Electron.Session; + + beforeEach(async () => { + customSession = session.fromPartition('resolvehost'); + }); + + afterEach(() => { + customSession = null as any; + }); + + it('resolves ipv4.localhost2', async () => { + const { endpoints } = await customSession.resolveHost('ipv4.localhost2'); + expect(endpoints).to.be.a('array'); + expect(endpoints).to.have.lengthOf(1); + expect(endpoints[0].family).to.equal('ipv4'); + expect(endpoints[0].address).to.equal('10.0.0.1'); + }); + + it('fails to resolve AAAA record for ipv4.localhost2', async () => { + await expect(customSession.resolveHost('ipv4.localhost2', { + queryType: 'AAAA' + })) + .to.eventually.be.rejectedWith(/net::ERR_NAME_NOT_RESOLVED/); + }); + + it('resolves ipv6.localhost2', async () => { + const { endpoints } = await customSession.resolveHost('ipv6.localhost2'); + expect(endpoints).to.be.a('array'); + expect(endpoints).to.have.lengthOf(1); + expect(endpoints[0].family).to.equal('ipv6'); + expect(endpoints[0].address).to.equal('::1'); + }); + + it('fails to resolve A record for ipv6.localhost2', async () => { + await expect(customSession.resolveHost('notfound.localhost2', { + queryType: 'A' + })) + .to.eventually.be.rejectedWith(/net::ERR_NAME_NOT_RESOLVED/); + }); + + it('fails to resolve notfound.localhost2', async () => { + await expect(customSession.resolveHost('notfound.localhost2')) + .to.eventually.be.rejectedWith(/net::ERR_NAME_NOT_RESOLVED/); + }); + }); + describe('ses.getBlobData()', () => { const scheme = 'cors-blob'; const protocol = session.defaultSession.protocol; diff --git a/spec/index.js b/spec/index.js index 80cdc341ce..8e9b2aa155 100644 --- a/spec/index.js +++ b/spec/index.js @@ -22,6 +22,11 @@ app.on('window-all-closed', () => null); // Use fake device for Media Stream to replace actual camera and microphone. app.commandLine.appendSwitch('use-fake-device-for-media-stream'); app.commandLine.appendSwitch('host-rules', 'MAP localhost2 127.0.0.1'); +app.commandLine.appendSwitch('host-resolver-rules', [ + 'MAP ipv4.localhost2 10.0.0.1', + 'MAP ipv6.localhost2 [::1]', + 'MAP notfound.localhost2 ~NOTFOUND' +].join(', ')); global.standardScheme = 'app'; global.zoomScheme = 'zoom';