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

Allow requiring basic HTTP authentication #3131

Merged
merged 13 commits into from
Feb 20, 2024
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ tempfile = "3.2.0"
tokio = { version = "1.17.0", features = ["rt-multi-thread"] }
tokio-stream = "0.1.9"
tokio-util = {version = "0.7.3", features = ["compat"] }
tower-http = { version = "0.4.0", features = ["compression-br", "compression-gzip", "cors", "set-header"] }
tower-http = { version = "0.4.0", features = ["auth", "compression-br", "compression-gzip", "cors", "set-header"] }

[dev-dependencies]
criterion = "0.5.1"
Expand Down
16 changes: 16 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ pub struct Options {
help = "Do not index inscriptions."
)]
pub(crate) no_index_inscriptions: bool,
#[arg(
long,
requires = "username",
help = "Require basic HTTP authentication with <PASSWORD>. Credentials are sent in cleartext. Consider using authentication in conjunction with HTTPS."
)]
pub(crate) password: Option<String>,
#[arg(long, short, help = "Use regtest. Equivalent to `--chain regtest`.")]
pub(crate) regtest: bool,
#[arg(long, help = "Connect to Bitcoin Core RPC at <RPC_URL>.")]
Expand All @@ -70,6 +76,12 @@ pub struct Options {
pub(crate) signet: bool,
#[arg(long, short, help = "Use testnet. Equivalent to `--chain testnet`.")]
pub(crate) testnet: bool,
#[arg(
long,
requires = "password",
help = "Require basic HTTP authentication with <USERNAME>. Credentials are sent in cleartext. Consider using authentication in conjunction with HTTPS."
)]
pub(crate) username: Option<String>,
}

impl Options {
Expand Down Expand Up @@ -141,6 +153,10 @@ impl Options {
Ok(path.join(".cookie"))
}

pub(crate) fn credentials(&self) -> Option<(&str, &str)> {
self.username.as_deref().zip(self.password.as_deref())
}

fn default_data_dir() -> PathBuf {
dirs::data_dir()
.map(|dir| dir.join("ord"))
Expand Down
43 changes: 33 additions & 10 deletions src/subcommand/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ use {
compression::CompressionLayer,
cors::{Any, CorsLayer},
set_header::SetResponseHeaderLayer,
validate_request::ValidateRequestHeaderLayer,
},
};

Expand Down Expand Up @@ -150,6 +151,13 @@ pub struct Server {
help = "Use <CSP_ORIGIN> in Content-Security-Policy header. Set this to the public-facing URL of your ord instance."
)]
pub(crate) csp_origin: Option<String>,
#[arg(
long,
help = "Decompress encoded content. Currently only supports brotli. Be careful using this on production instances. A decompressed inscription may be arbitrarily large, making decompression a DoS vector."
)]
pub(crate) decompress: bool,
#[arg(long, help = "Disable JSON API.")]
pub(crate) disable_json_api: bool,
#[arg(
long,
help = "Listen on <HTTP_PORT> for incoming HTTP requests. [default: 80]"
Expand All @@ -171,15 +179,14 @@ pub struct Server {
pub(crate) https: bool,
#[arg(long, help = "Redirect HTTP traffic to HTTPS.")]
pub(crate) redirect_http_to_https: bool,
#[arg(long, help = "Disable JSON API.")]
pub(crate) disable_json_api: bool,
#[arg(long, alias = "nosync", help = "Do not update the index.")]
pub(crate) no_sync: bool,
#[arg(
long,
help = "Decompress encoded content. Currently only supports brotli. Be careful using this on production instances. A decompressed inscription may be arbitrarily large, making decompression a DoS vector."
default_value = "5s",
help = "Poll Bitcoin Core every <POLLING_INTERVAL>."
)]
pub(crate) decompress: bool,
#[arg(long, alias = "nosync", help = "Do not update the index.")]
pub(crate) no_sync: bool,
pub(crate) polling_interval: humantime::Duration,
}

impl Server {
Expand All @@ -198,11 +205,11 @@ impl Server {
}
}

if integration_test() {
thread::sleep(Duration::from_millis(100));
thread::sleep(if integration_test() {
Duration::from_millis(100)
} else {
thread::sleep(Duration::from_millis(5000));
}
self.polling_interval.into()
});
});

INDEXER.lock().unwrap().replace(index_thread);
Expand Down Expand Up @@ -310,6 +317,12 @@ impl Server {
.layer(CompressionLayer::new())
.with_state(server_config);

let router = if let Some((username, password)) = options.credentials() {
router.layer(ValidateRequestHeaderLayer::basic(username, password))
} else {
router
};

match (self.http_port(), self.https_port()) {
(Some(http_port), None) => {
self
Expand Down Expand Up @@ -5342,4 +5355,14 @@ next
},
)
}

#[test]
fn authentication_requires_username_and_password() {
assert!(Arguments::try_parse_from(["ord", "--username", "server", "foo"]).is_err());
assert!(Arguments::try_parse_from(["ord", "--password", "server", "bar"]).is_err());
assert!(
Arguments::try_parse_from(["ord", "--username", "foo", "--password", "bar", "server"])
.is_ok()
);
}
}
17 changes: 16 additions & 1 deletion src/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,22 @@ impl Wallet {

pub(crate) fn ord_client(&self) -> Result<reqwest::blocking::Client> {
let mut headers = header::HeaderMap::new();

headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);

if let Some((username, password)) = self.options.credentials() {
use base64::Engine;
let credentials =
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("Basic {credentials}")).unwrap(),
);
}

let client = reqwest::blocking::ClientBuilder::new()
.default_headers(headers)
.build()
Expand All @@ -57,7 +68,11 @@ impl Wallet {
.get(self.ord_url.join("/blockcount").unwrap())
.send()?;

assert_eq!(response.status(), StatusCode::OK);
ensure!(
response.status() == StatusCode::OK,
"request to server failed with status: {}",
response.status()
);

if response.text()?.parse::<u64>().unwrap() >= chain_block_count {
break;
Expand Down
44 changes: 44 additions & 0 deletions tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,3 +584,47 @@ fn run_no_sync() {

child.kill().unwrap();
}

#[test]
fn authentication() {
let rpc_server = test_bitcoincore_rpc::spawn();

let port = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();

let builder = CommandBuilder::new(format!(
" --username foo --password bar server --address 127.0.0.1 --http-port {port}"
))
.bitcoin_rpc_server(&rpc_server);

let mut command = builder.command();

let mut child = command.spawn().unwrap();

for attempt in 0.. {
if let Ok(response) = reqwest::blocking::get(format!("http://localhost:{port}")) {
if response.status() == 401 {
break;
}
}

if attempt == 100 {
panic!("Server did not respond");
}

thread::sleep(Duration::from_millis(50));
}

let response = reqwest::blocking::Client::new()
.get(format!("http://localhost:{port}"))
.basic_auth("foo", Some("bar"))
.send()
.unwrap();

assert_eq!(response.status(), 200);

child.kill().unwrap();
}
1 change: 1 addition & 0 deletions tests/wallet.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::*;

mod authentication;
mod balance;
mod cardinals;
mod create;
Expand Down
39 changes: 39 additions & 0 deletions tests/wallet/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use {super::*, ord::subcommand::wallet::balance::Output};

#[test]
fn authentication() {
let bitcoin_rpc_server = test_bitcoincore_rpc::spawn();

let ord_rpc_server = TestServer::spawn_with_server_args(
&bitcoin_rpc_server,
&["--username", "foo", "--password", "bar"],
&[],
);

create_wallet(&bitcoin_rpc_server, &ord_rpc_server);

assert_eq!(
CommandBuilder::new("--username foo --password bar wallet balance")
.bitcoin_rpc_server(&bitcoin_rpc_server)
.ord_rpc_server(&ord_rpc_server)
.run_and_deserialize_output::<Output>()
.cardinal,
0
);

bitcoin_rpc_server.mine_blocks(1);

assert_eq!(
CommandBuilder::new("--username foo --password bar wallet balance")
.bitcoin_rpc_server(&bitcoin_rpc_server)
.ord_rpc_server(&ord_rpc_server)
.run_and_deserialize_output::<Output>(),
Output {
cardinal: 50 * COIN_VALUE,
ordinal: 0,
runic: None,
runes: None,
total: 50 * COIN_VALUE,
}
);
}
Loading