-
Notifications
You must be signed in to change notification settings - Fork 0
feat(account): wire premium accounts end to end #171
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
Merged
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
02769b0
test: define premium account domain contract
mpiton 7ec68fe
test: require premium account persistence columns
mpiton d076a81
feat: persist typed premium account state
mpiton 10ad93e
test: define scoped plugin credential lifecycle
mpiton 427f2b4
feat: scope account credentials to plugin calls
mpiton a6da481
test: define plugin account validation bridge
mpiton d06cdb7
feat: bridge premium validation through plugins
mpiton ca6c548
test: require automatic account validation
mpiton d3e066d
feat: validate accounts on credential changes
mpiton 07ec064
test: require state-aware account rotation
mpiton c770be2
feat: persist account rotation state
mpiton 8df2028
test(accounts): define premium resolution contract
mpiton 2099d20
feat(accounts): resolve hoster links with rotation
mpiton c7a07fd
test(accounts): define premium UI contract
mpiton 2302908
feat(accounts): surface premium state in UI
mpiton 3068d36
feat(accounts): wire premium services at runtime
mpiton 8977c00
test(accounts): cover premium fallback states
mpiton 1e1248f
test(plugin): expose credential boundary regressions
mpiton 5ee3095
fix(plugin): bind credentials to selected plugin
mpiton c2053cf
test(accounts): expose state transition regressions
mpiton da088ee
fix(accounts): harden state transitions and rotation
mpiton 91f0771
test(accounts): keep direct URLs inside native runtime
mpiton dbefb28
feat(accounts): resolve premium URLs just in time
mpiton ba81261
test(accounts): expose remaining UI contract gaps
mpiton 8fff135
feat(accounts): expose typed validation status
mpiton e64058e
test(accounts): capture final review regressions
mpiton fa680e2
fix(accounts): close premium review gaps
mpiton 3e0f273
test(accounts): pin final acceptance gaps
mpiton b4330ca
fix(accounts): enforce final premium invariants
mpiton 0e65fb2
fix(account): close final premium acceptance gaps
mpiton 0db4d05
chore(registry): bump vortex-mod-1fichier to 1.1.0
mpiton 38c12d3
fix: address PR review comments
mpiton 13123fd
fix: address PR review comments
mpiton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
375 changes: 328 additions & 47 deletions
375
src-tauri/src/adapters/driven/network/download_engine.rs
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs}; | ||
| use std::sync::OnceLock; | ||
|
|
||
| const PREFIX_LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96]; | ||
| const WELL_KNOWN_IPV4: [Ipv4Addr; 2] = | ||
| [Ipv4Addr::new(192, 0, 0, 170), Ipv4Addr::new(192, 0, 0, 171)]; | ||
|
|
||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| pub(super) struct Nat64Prefix { | ||
| network: u128, | ||
| length: u8, | ||
| } | ||
|
|
||
| impl Nat64Prefix { | ||
| pub(super) fn new(address: Ipv6Addr, length: u8) -> Option<Self> { | ||
| if !PREFIX_LENGTHS.contains(&length) { | ||
| return None; | ||
| } | ||
| Some(Self { | ||
| network: u128::from(address) & prefix_mask(length), | ||
| length, | ||
| }) | ||
| } | ||
|
|
||
| pub(super) fn embedded_ipv4(self, address: Ipv6Addr) -> Option<Ipv4Addr> { | ||
| if u128::from(address) & prefix_mask(self.length) != self.network { | ||
| return None; | ||
| } | ||
| let bytes = address.octets(); | ||
| if self.length < 96 && bytes[8] != 0 { | ||
| return None; | ||
| } | ||
| let octets = match self.length { | ||
| 32 => [bytes[4], bytes[5], bytes[6], bytes[7]], | ||
| 40 => [bytes[5], bytes[6], bytes[7], bytes[9]], | ||
| 48 => [bytes[6], bytes[7], bytes[9], bytes[10]], | ||
| 56 => [bytes[7], bytes[9], bytes[10], bytes[11]], | ||
| 64 => [bytes[9], bytes[10], bytes[11], bytes[12]], | ||
| 96 => [bytes[12], bytes[13], bytes[14], bytes[15]], | ||
| _ => return None, | ||
| }; | ||
| Some(Ipv4Addr::from(octets)) | ||
| } | ||
| } | ||
|
|
||
| pub(super) fn discovered_prefixes() -> &'static [Nat64Prefix] { | ||
| static PREFIXES: OnceLock<Vec<Nat64Prefix>> = OnceLock::new(); | ||
| PREFIXES.get_or_init(|| { | ||
| let known = Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0); | ||
| let mut prefixes = Nat64Prefix::new(known, 96).into_iter().collect(); | ||
| if let Ok(addresses) = ("ipv4only.arpa", 80).to_socket_addrs() { | ||
| for address in addresses.filter_map(|address| match address.ip() { | ||
| std::net::IpAddr::V6(ip) => Some(ip), | ||
| std::net::IpAddr::V4(_) => None, | ||
| }) { | ||
| discover_from_address(address, &mut prefixes); | ||
| } | ||
| } | ||
| prefixes | ||
| }) | ||
| } | ||
|
|
||
| fn discover_from_address(address: Ipv6Addr, prefixes: &mut Vec<Nat64Prefix>) { | ||
| for length in PREFIX_LENGTHS { | ||
| let Some(prefix) = Nat64Prefix::new(address, length) else { | ||
| continue; | ||
| }; | ||
| if prefix | ||
| .embedded_ipv4(address) | ||
| .is_some_and(|ipv4| WELL_KNOWN_IPV4.contains(&ipv4)) | ||
| && !prefixes.contains(&prefix) | ||
| { | ||
| prefixes.push(prefix); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn prefix_mask(length: u8) -> u128 { | ||
| u128::MAX << (128 - length) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn extracts_all_rfc_6052_layouts() { | ||
| for (address, length) in [ | ||
| ("2001:db8:c000:221::", 32), | ||
| ("2001:db8:1c0:2:21::", 40), | ||
| ("2001:db8:122:c000:2:2100::", 48), | ||
| ("2001:db8:122:3c0:0:221::", 56), | ||
| ("2001:db8:122:344:c0:2:2100::", 64), | ||
| ("2001:db8:122:344::c000:221", 96), | ||
| ] { | ||
| let address = address.parse().unwrap(); | ||
| let prefix = Nat64Prefix::new(address, length).unwrap(); | ||
| assert_eq!( | ||
| prefix.embedded_ipv4(address), | ||
| Some(Ipv4Addr::new(192, 0, 2, 33)) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn accepts_96_prefixes_with_nonzero_fifth_segment() { | ||
| let address: Ipv6Addr = "2606:4700:64:1:ab00:cd00:c0a8:1".parse().unwrap(); | ||
| let prefix = Nat64Prefix::new(address, 96).expect("valid /96 prefix"); | ||
|
|
||
| assert_eq!( | ||
| prefix.embedded_ipv4(address), | ||
| Some(Ipv4Addr::new(192, 168, 0, 1)) | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| //! Network policy for URLs supplied by plugins rather than users. | ||
|
|
||
| use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; | ||
| use std::time::Duration; | ||
|
|
||
| use crate::domain::error::DomainError; | ||
|
|
||
| use super::nat64::{Nat64Prefix, discovered_prefixes}; | ||
|
|
||
| pub(crate) fn validate_public_url( | ||
| url: &reqwest::Url, | ||
| ) -> Result<Option<Vec<SocketAddr>>, DomainError> { | ||
| if url.scheme() != "https" { | ||
| return Err(DomainError::NetworkError( | ||
| "plugin URL must use HTTPS".into(), | ||
| )); | ||
| } | ||
| let host = url | ||
| .host_str() | ||
| .ok_or_else(|| DomainError::NetworkError("URL has no host".into()))?; | ||
| if host == "localhost" || host.ends_with(".localhost") { | ||
| return Err(blocked()); | ||
| } | ||
| if let Ok(ip) = host.parse::<IpAddr>() { | ||
| return if is_forbidden_ip(&ip) { | ||
| Err(blocked()) | ||
| } else { | ||
| Ok(None) | ||
| }; | ||
| } | ||
| let port = url | ||
| .port_or_known_default() | ||
| .ok_or_else(|| DomainError::NetworkError("URL has no known port".into()))?; | ||
| let addresses = (host, port) | ||
| .to_socket_addrs() | ||
| .map_err(|_| DomainError::NetworkError("host resolution failed".into()))? | ||
| .collect::<Vec<_>>(); | ||
| if addresses.is_empty() || addresses.iter().any(|addr| is_forbidden_ip(&addr.ip())) { | ||
| return Err(blocked()); | ||
| } | ||
| Ok(Some(addresses)) | ||
| } | ||
|
|
||
| pub(crate) fn restricted_download_client( | ||
| url: &reqwest::Url, | ||
| ) -> Result<reqwest::Client, DomainError> { | ||
| if !url.username().is_empty() || url.password().is_some() { | ||
| return Err(DomainError::NetworkError( | ||
| "plugin download URL must be credential-free HTTPS".into(), | ||
| )); | ||
| } | ||
| let addresses = validate_public_url(url)?; | ||
| let mut builder = reqwest::Client::builder() | ||
| .no_proxy() | ||
| .user_agent("Vortex/0.1") | ||
| .redirect(reqwest::redirect::Policy::none()) | ||
| .connect_timeout(Duration::from_secs(30)) | ||
| .timeout(Duration::from_secs(3600)); | ||
| if let (Some(host), Some(addresses)) = (url.host_str(), addresses.as_deref()) { | ||
| builder = builder.resolve_to_addrs(host, addresses); | ||
| } | ||
| builder | ||
| .build() | ||
| .map_err(|_| DomainError::NetworkError("restricted HTTP client creation failed".into())) | ||
| } | ||
|
|
||
| fn blocked() -> DomainError { | ||
| DomainError::NetworkError("plugin URL targets a non-public network".into()) | ||
| } | ||
|
|
||
| pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool { | ||
| let normalized = match ip { | ||
| IpAddr::V6(ip) if ip.to_ipv4_mapped().is_some() => IpAddr::V4( | ||
| ip.to_ipv4_mapped() | ||
| .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED), | ||
| ), | ||
| other => *other, | ||
| }; | ||
| match normalized { | ||
| IpAddr::V4(ip) => { | ||
| let [a, b, c, d] = ip.octets(); | ||
| a == 0 | ||
| || a == 10 | ||
| || a == 127 | ||
| || (a == 100 && (64..=127).contains(&b)) | ||
| || (a == 169 && b == 254) | ||
| || (a == 172 && (16..=31).contains(&b)) | ||
| || (a == 192 && b == 0 && c == 0) | ||
| || (a == 192 && b == 0 && c == 2) | ||
| || (a == 192 && b == 168) | ||
| || (a == 198 && (b == 18 || b == 19)) | ||
| || (a == 198 && b == 51 && c == 100) | ||
| || (a == 203 && b == 0 && c == 113) | ||
| || a >= 224 | ||
| || (a == 255 && b == 255 && c == 255 && d == 255) | ||
| } | ||
| IpAddr::V6(ip) => is_forbidden_ipv6(&ip, discovered_prefixes()), | ||
| } | ||
| } | ||
|
|
||
| fn is_forbidden_ipv6(ip: &std::net::Ipv6Addr, nat64: &[Nat64Prefix]) -> bool { | ||
| let segments = ip.segments(); | ||
| let first = segments[0]; | ||
| ip.is_loopback() | ||
| || ip.is_unspecified() | ||
| || ip.is_multicast() | ||
| || (first & 0xfe00) == 0xfc00 | ||
| || (first & 0xffc0) == 0xfe80 | ||
| || (first & 0xffc0) == 0xfec0 | ||
| || matches!(segments, [0x0064, 0xff9b, 0x0001, ..]) | ||
| || matches!(segments, [0x0100, 0, 0, 0, ..]) | ||
| || is_orchid(&segments) | ||
| || matches!(segments, [0x2001, 0x0002, 0, ..]) | ||
| || matches!(segments, [0x2001, 0x0db8, ..]) | ||
| || matches!(segments, [0x2002, ..]) | ||
| || matches!(segments, [0x3ff0..=0x3fff, ..]) | ||
| || matches!(segments, [0x5f00, ..]) | ||
| || nat64.iter().any(|prefix| { | ||
| prefix | ||
| .embedded_ipv4(*ip) | ||
| .is_some_and(|ipv4| is_forbidden_ip(&IpAddr::V4(ipv4))) | ||
| }) | ||
| } | ||
|
|
||
| fn is_orchid(segments: &[u16; 8]) -> bool { | ||
| segments[0] == 0x2001 && matches!(segments[1] & 0xfff0, 0x0010 | 0x0020 | 0x0030) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "safe_url_tests.rs"] | ||
| mod tests; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn rejects_loopback_private_link_local_and_mapped_addresses() { | ||
| for raw in [ | ||
| "127.0.0.1", | ||
| "10.0.0.1", | ||
| "169.254.169.254", | ||
| "::ffff:127.0.0.1", | ||
| "fec0::1", | ||
| "64:ff9b::c0a8:1", | ||
| "64:ff9b:1::c0a8:1", | ||
| "2001:10::1", | ||
| "2001:20::1", | ||
| ] { | ||
| assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() { | ||
| let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap(); | ||
| let target = "2606:4700:64::c0a8:1".parse().unwrap(); | ||
|
|
||
| assert!(is_forbidden_ipv6(&target, &[prefix])); | ||
| } | ||
|
|
||
| #[test] | ||
| fn accepts_globally_routable_ipv6_address() { | ||
| assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); | ||
| } | ||
|
|
||
| #[test] | ||
| fn restricted_client_requires_https_and_public_destination() { | ||
| let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); | ||
| let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap(); | ||
| assert!(restricted_download_client(&http).is_err()); | ||
| assert!(restricted_download_client(&local).is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn plugin_http_validator_rejects_cleartext_urls() { | ||
| let url = reqwest::Url::parse("http://1.1.1.1/account").unwrap(); | ||
|
|
||
| assert!(validate_public_url(&url).is_err()); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.