Skip to content

Commit bc80401

Browse files
committed
Add address check for postgres
Signed-off-by: Ryan Levick <[email protected]>
1 parent 36d04ec commit bc80401

File tree

7 files changed

+183
-80
lines changed

7 files changed

+183
-80
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/outbound-networking/src/lib.rs

Lines changed: 117 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,45 +14,112 @@ pub fn check_address(
1414
allowed_hosts: &Option<AllowedHosts>,
1515
default: bool,
1616
) -> bool {
17-
let Ok(url) = parse_url_with_host(address, scheme) else {
17+
let Ok(address) = Address::parse(address, Some(scheme)) else {
1818
terminal::warn!(
19-
"A component tried to make a request to an address that could not be parsed as a url {address:?}."
20-
);
19+
"A component tried to make a request to an address that could not be parsed {address}.",
20+
);
2121
return false;
2222
};
2323
let is_allowed = if let Some(allowed_hosts) = allowed_hosts {
24-
allowed_hosts.allows(url.clone())
24+
allowed_hosts.allows(&address)
2525
} else {
2626
default
2727
};
2828

2929
if !is_allowed {
30-
terminal::warn!("A component tried to make a request to non-allowed address {address:?}.");
31-
if let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) {
32-
eprintln!("To allow requests, add 'allowed_outbound_hosts = '[\"{host}:{port}\"]' to the manifest component section.");
33-
}
30+
terminal::warn!("A component tried to make a request to non-allowed address '{address}'.");
31+
let (host, port) = (address.host(), address.port());
32+
eprintln!("To allow requests, add 'allowed_outbound_hosts = '[\"{host}:{port}\"]' to the manifest component section.");
3433
}
3534
is_allowed
3635
}
3736

38-
/// Try to parse the url that may or not include the provided scheme.
39-
///
40-
/// If the parsing fails, the url is appended with the scheme and parsing
41-
/// is tried again.
42-
pub fn parse_url_with_host(url: &str, scheme: &str) -> anyhow::Result<Url> {
43-
match Url::parse(url) {
44-
Ok(url) if url.has_host() => Ok(url),
45-
first_try => {
46-
let second_try = format!("{scheme}://{url}")
47-
.as_str()
48-
.try_into()
49-
.context("could not convert into a url");
50-
match (second_try, first_try.map_err(|e| e.into())) {
51-
(Ok(u), _) => Ok(u),
52-
// Return an error preferring the error from the first attempt if present
53-
(_, Err(e)) | (Err(e), _) => Err(e),
37+
/// An address is a url-like string that contains a host, a port, and an optional scheme
38+
struct Address {
39+
inner: Url,
40+
original: String,
41+
has_scheme: bool,
42+
}
43+
44+
impl Address {
45+
/// Try to parse the address.
46+
///
47+
/// If the parsing fails, the address is prepended with the scheme and parsing
48+
/// is tried again.
49+
pub fn parse(url: &str, scheme: Option<&str>) -> anyhow::Result<Self> {
50+
let mut has_scheme = true;
51+
let mut parsed = match Url::parse(url) {
52+
Ok(url) if url.has_host() => Ok(url),
53+
first_try => {
54+
// Parsing with 'scheme' resolves the ambiguity between 'spin.fermyon.com:80' and 'unix:80'.
55+
// Technically according to the spec a valid url *must* contain a scheme. However,
56+
// we allow url-like address strings without schemes, and we interpret the first part as the host.
57+
let second_try = format!("{}://{url}", scheme.unwrap_or("scheme"))
58+
.as_str()
59+
.try_into()
60+
.context("could not convert into a url");
61+
has_scheme = false;
62+
match (second_try, first_try.map_err(|e| e.into())) {
63+
(Ok(u), _) => Ok(u),
64+
// Return an error preferring the error from the first attempt if present
65+
(_, Err(e)) | (Err(e), _) => Err(e),
66+
}
5467
}
68+
}?;
69+
70+
if parsed.port_or_known_default().is_none() {
71+
let _ = parsed.set_port(well_known_port(parsed.scheme()));
72+
}
73+
74+
Ok(Self {
75+
inner: parsed,
76+
has_scheme,
77+
original: url.to_owned(),
78+
})
79+
}
80+
81+
fn scheme(&self) -> Option<&str> {
82+
self.has_scheme.then_some(self.inner.scheme())
83+
}
84+
85+
fn host(&self) -> &str {
86+
self.inner.host_str().unwrap_or_default()
87+
}
88+
89+
fn port(&self) -> u16 {
90+
self.inner
91+
.port_or_known_default()
92+
.or_else(|| well_known_port(self.scheme()?))
93+
.unwrap_or_default()
94+
}
95+
96+
fn validate_as_config(&self) -> anyhow::Result<()> {
97+
if !["", "/"].contains(&self.inner.path()) {
98+
anyhow::bail!("config '{}' contains a path", self);
99+
}
100+
if self.inner.query().is_some() {
101+
anyhow::bail!("config '{}' contains a query string", self);
102+
}
103+
if self.port() == 0 {
104+
anyhow::bail!("config '{}' did not contain port", self)
55105
}
106+
107+
Ok(())
108+
}
109+
}
110+
111+
impl std::fmt::Display for Address {
112+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113+
f.write_str(&self.original)
114+
}
115+
}
116+
117+
fn well_known_port(scheme: &str) -> Option<u16> {
118+
match scheme {
119+
"postgres" => Some(5432),
120+
"mysql" => Some(3306),
121+
"redis" => Some(6379),
122+
_ => None,
56123
}
57124
}
58125

@@ -75,15 +142,10 @@ impl AllowedHosts {
75142
Ok(Self::SpecificHosts(allowed))
76143
}
77144

78-
pub fn allows<U: TryInto<Url>>(&self, url: U) -> bool {
145+
fn allows(&self, address: &Address) -> bool {
79146
match self {
80147
AllowedHosts::All => true,
81-
AllowedHosts::SpecificHosts(hosts) => {
82-
let Ok(url) = url.try_into() else {
83-
return false;
84-
};
85-
hosts.iter().any(|h| h.allows(&url))
86-
}
148+
AllowedHosts::SpecificHosts(hosts) => hosts.iter().any(|h| h.allows(address)),
87149
}
88150
}
89151
}
@@ -103,53 +165,24 @@ pub struct AllowedHost {
103165

104166
impl AllowedHost {
105167
fn parse<U: AsRef<str>>(url: U) -> anyhow::Result<Self> {
106-
let url_str = url.as_ref();
107-
let url: anyhow::Result<Url> = url_str
108-
.try_into()
109-
.with_context(|| format!("could not convert {url_str:?} into a url"));
110-
let (url, has_scheme) = match url {
111-
Ok(url) if url.has_host() => (url, true),
112-
first_try => {
113-
// If the url doesn't successfully parse try again with an added scheme.
114-
// This resolves the ambiguity between 'spin.fermyon.com:80' and 'unix:80'.
115-
// Technically according to the spec a valid url *must* contain a scheme. However,
116-
// we allow url-like strings without schemes, and we interpret the first part as the host.
117-
let second_try = format!("scheme://{url_str}")
118-
.as_str()
119-
.try_into()
120-
.context("could not convert into a url");
121-
match (second_try, first_try) {
122-
(Ok(u), _) => (u, false),
123-
// Return an error preferring the error from the first attempt if present
124-
(_, Err(e)) | (Err(e), _) => return Err(e),
125-
}
126-
}
127-
};
128-
let host = url.host_str().context("the url has no host")?.to_owned();
168+
let address = Address::parse(url.as_ref(), None)?;
169+
address.validate_as_config()?;
129170

130-
if !["", "/"].contains(&url.path()) {
131-
anyhow::bail!("url contains a path")
132-
}
133-
if url.query().is_some() {
134-
anyhow::bail!("url contains a query string")
135-
}
136171
Ok(Self {
137-
scheme: has_scheme.then(|| url.scheme().to_owned()),
138-
host,
139-
port: url
140-
.port_or_known_default()
141-
.context("url did not contain port")?,
172+
scheme: address.scheme().map(ToOwned::to_owned),
173+
host: address.host().to_owned(),
174+
port: address.port(),
142175
})
143176
}
144177

145-
fn allows(&self, url: &Url) -> bool {
178+
fn allows(&self, address: &Address) -> bool {
146179
let scheme_matches = self
147180
.scheme
148-
.as_ref()
149-
.map(|s| s == url.scheme())
181+
.as_deref()
182+
.map(|s| Some(s) == address.scheme())
150183
.unwrap_or(true);
151-
let host_matches = url.host_str().unwrap_or_default() == self.host;
152-
let port_matches = url.port_or_known_default().unwrap_or_default() == self.port;
184+
let host_matches = address.host() == self.host;
185+
let port_matches = address.port() == self.port;
153186

154187
scheme_matches && host_matches && port_matches
155188
}
@@ -170,7 +203,7 @@ mod test {
170203
use super::*;
171204

172205
#[test]
173-
fn test_allowed_hosts_accepts_http_url() {
206+
fn test_allowed_hosts_accepts_url() {
174207
assert_eq!(
175208
AllowedHost::new(Some("http"), "spin.fermyon.dev", 80),
176209
AllowedHost::parse("http://spin.fermyon.dev").unwrap()
@@ -183,10 +216,14 @@ mod test {
183216
AllowedHost::new(Some("https"), "spin.fermyon.dev", 443),
184217
AllowedHost::parse("https://spin.fermyon.dev").unwrap()
185218
);
219+
assert_eq!(
220+
AllowedHost::new(Some("postgres"), "spin.fermyon.dev", 5432),
221+
AllowedHost::parse("postgres://spin.fermyon.dev").unwrap()
222+
);
186223
}
187224

188225
#[test]
189-
fn test_allowed_hosts_accepts_http_url_with_port() {
226+
fn test_allowed_hosts_accepts_url_with_port() {
190227
assert_eq!(
191228
AllowedHost::new(Some("http"), "spin.fermyon.dev", 4444),
192229
AllowedHost::parse("http://spin.fermyon.dev:4444").unwrap()
@@ -281,9 +318,13 @@ mod test {
281318
fn test_allowed_hosts_can_be_specific() {
282319
let allowed =
283320
AllowedHosts::parse(&["spin.fermyon.dev:443", "http://example.com:8383"]).unwrap();
284-
assert!(allowed.allows(Url::parse("http://example.com:8383/foo/bar").unwrap()));
285-
assert!(allowed.allows(Url::parse("https://spin.fermyon.dev/").unwrap()));
286-
assert!(!allowed.allows(Url::parse("http://example.com/").unwrap()));
287-
assert!(!allowed.allows(Url::parse("http://google.com/").unwrap()));
321+
assert!(allowed
322+
.allows(&Address::parse("http://example.com:8383/foo/bar", Some("http")).unwrap()));
323+
assert!(
324+
allowed.allows(&Address::parse("https://spin.fermyon.dev/", Some("https")).unwrap())
325+
);
326+
assert!(!allowed.allows(&Address::parse("http://example.com/", Some("http")).unwrap()));
327+
assert!(!allowed.allows(&Address::parse("http://google.com/", Some("http")).unwrap()));
328+
assert!(allowed.allows(&Address::parse("spin.fermyon.dev:443", Some("https")).unwrap()));
288329
}
289330
}

crates/outbound-pg/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ doctest = false
1111
anyhow = "1.0"
1212
native-tls = "0.2.11"
1313
postgres-native-tls = "0.5.0"
14+
spin-app = { path = "../app" }
1415
spin-core = { path = "../core" }
16+
spin-outbound-networking = { path = "../outbound-networking" }
1517
spin-world = { path = "../world" }
1618
table = { path = "../table" }
1719
tokio = { version = "1", features = ["rt-multi-thread"] }

crates/outbound-pg/src/lib.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use anyhow::{anyhow, Result};
1+
use anyhow::{anyhow, Context, Result};
22
use native_tls::TlsConnector;
33
use postgres_native_tls::MakeTlsConnector;
4+
use spin_app::DynamicHostComponent;
45
use spin_core::{async_trait, wasmtime::component::Resource, HostComponent};
56
use spin_world::v1::postgres as v1;
67
use spin_world::v1::rdbms_types as v1_types;
@@ -15,6 +16,7 @@ use tokio_postgres::{
1516
/// A simple implementation to support outbound pg connection
1617
#[derive(Default)]
1718
pub struct OutboundPg {
19+
allowed_hosts: Option<spin_outbound_networking::AllowedHosts>,
1820
pub connections: table::Table<Client>,
1921
}
2022

@@ -24,6 +26,29 @@ impl OutboundPg {
2426
.get(connection.rep())
2527
.ok_or_else(|| v2::Error::ConnectionFailed("no connection found".into()))
2628
}
29+
30+
fn is_address_allowed(&self, address: &str, default: bool) -> bool {
31+
let Ok(config) = address.parse::<tokio_postgres::Config>() else {
32+
return false;
33+
};
34+
for host in config.get_hosts() {
35+
match host {
36+
tokio_postgres::config::Host::Tcp(address) => {
37+
if !spin_outbound_networking::check_address(
38+
address,
39+
"postgres",
40+
&self.allowed_hosts,
41+
default,
42+
) {
43+
return false;
44+
}
45+
}
46+
#[cfg(unix)]
47+
tokio_postgres::config::Host::Unix(_) => return false,
48+
}
49+
}
50+
true
51+
}
2752
}
2853

2954
impl HostComponent for OutboundPg {
@@ -42,12 +67,34 @@ impl HostComponent for OutboundPg {
4267
}
4368
}
4469

70+
impl DynamicHostComponent for OutboundPg {
71+
fn update_data(
72+
&self,
73+
data: &mut Self::Data,
74+
component: &spin_app::AppComponent,
75+
) -> anyhow::Result<()> {
76+
let hosts = component
77+
.get_metadata(spin_outbound_networking::ALLOWED_HOSTS_KEY)?
78+
.unwrap_or_default();
79+
data.allowed_hosts = hosts
80+
.map(|h| spin_outbound_networking::AllowedHosts::parse(&h[..]))
81+
.transpose()
82+
.context("`allowed_outbound_hosts` contained an invalid url")?;
83+
Ok(())
84+
}
85+
}
86+
4587
#[async_trait]
4688
impl v2::Host for OutboundPg {}
4789

4890
#[async_trait]
4991
impl v2::HostConnection for OutboundPg {
5092
async fn open(&mut self, address: String) -> Result<Result<Resource<Connection>, v2::Error>> {
93+
if !self.is_address_allowed(&address, false) {
94+
return Ok(Err(v2::Error::ConnectionFailed(format!(
95+
"address {address} is not permitted"
96+
))));
97+
}
5198
Ok(async {
5299
self.connections
53100
.push(
@@ -347,6 +394,11 @@ impl std::fmt::Debug for PgNull {
347394
/// Delegate a function call to the v2::HostConnection implementation
348395
macro_rules! delegate {
349396
($self:ident.$name:ident($address:expr, $($arg:expr),*)) => {{
397+
if !$self.is_address_allowed(&$address, true) {
398+
return Ok(Err(v1::PgError::ConnectionFailed(format!(
399+
"address {} is not permitted", $address
400+
))));
401+
}
350402
let connection = match <Self as v2::HostConnection>::open($self, $address).await? {
351403
Ok(c) => c,
352404
Err(e) => return Ok(Err(e.into())),

crates/trigger/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
112112

113113
if !self.disable_default_host_components {
114114
builder.link_import(|l, _| wasmtime_wasi_http::proxy::add_to_linker(l))?;
115-
builder.add_host_component(outbound_pg::OutboundPg::default())?;
116115
self.loader.add_dynamic_host_component(
117116
&mut builder,
118117
outbound_redis::OutboundRedisComponent,
@@ -121,6 +120,8 @@ impl<Executor: TriggerExecutor> TriggerExecutorBuilder<Executor> {
121120
&mut builder,
122121
outbound_mysql::OutboundMysql::default(),
123122
)?;
123+
self.loader
124+
.add_dynamic_host_component(&mut builder, outbound_pg::OutboundPg::default())?;
124125
self.loader.add_dynamic_host_component(
125126
&mut builder,
126127
runtime_config::llm::build_component(&runtime_config, init_data.llm.use_gpu)

examples/rust-outbound-pg/spin.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ component = "outbound-pg"
1212
[component.outbound-pg]
1313
environment = { DB_URL = "host=localhost user=postgres dbname=spin_dev" }
1414
source = "target/wasm32-wasi/release/rust_outbound_pg.wasm"
15+
allowed_outbound_hosts = ["localhost:5432"]
1516
[component.outbound-pg.build]
1617
command = "cargo build --target wasm32-wasi --release"

0 commit comments

Comments
 (0)