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

feat(rpc): Cookie auth system for the RPC endpoint #8900

Merged
merged 17 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6070,7 +6070,9 @@ dependencies = [
name = "zebra-rpc"
version = "1.0.0-beta.40"
dependencies = [
"base64 0.22.1",
"chrono",
"color-eyre",
"futures",
"hex",
"indexmap 2.5.0",
Expand Down
10 changes: 7 additions & 3 deletions zebra-rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ indexer-rpcs = [

# Mining RPC support
getblocktemplate-rpcs = [
"rand",
"zcash_address",
"zebra-consensus/getblocktemplate-rpcs",
"zebra-state/getblocktemplate-rpcs",
Expand Down Expand Up @@ -68,6 +67,13 @@ jsonrpc-http-server = "18.0.0"
serde_json = { version = "1.0.128", features = ["preserve_order"] }
indexmap = { version = "2.5.0", features = ["serde"] }

# RPC endpoint basic auth
base64 = "0.22.1"
rand = "0.8.5"

# Error handling
color-eyre = "0.6.3"

tokio = { version = "1.40.0", features = [
"time",
"rt-multi-thread",
Expand All @@ -92,8 +98,6 @@ nix = { version = "0.29.0", features = ["signal"] }

zcash_primitives = { workspace = true, features = ["transparent-inputs"] }

# Experimental feature getblocktemplate-rpcs
rand = { version = "0.8.5", optional = true }
# ECC deps used by getblocktemplate-rpcs feature
zcash_address = { workspace = true, optional = true}

Expand Down
1 change: 1 addition & 0 deletions zebra-rpc/qa/base_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ network = "Regtest"

[rpc]
listen_addr = "127.0.0.1:0"
enable_cookie_auth = false

[state]
cache_dir = ""
Expand Down
16 changes: 15 additions & 1 deletion zebra-rpc/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
//! User-configurable RPC settings.

use std::net::SocketAddr;
use std::{net::SocketAddr, path::PathBuf};

use serde::{Deserialize, Serialize};

use zebra_chain::common::default_cache_dir;

pub mod mining;

/// RPC configuration section.
Expand Down Expand Up @@ -71,6 +73,12 @@ pub struct Config {
/// Test-only option that makes Zebra say it is at the chain tip,
/// no matter what the estimated height or local clock is.
pub debug_force_finished_sync: bool,

/// The directory where Zebra stores RPC cookies.
pub cookie_dir: PathBuf,

/// Enable cookie-based authentication for RPCs.
pub enable_cookie_auth: bool,
}

// This impl isn't derivable because it depends on features.
Expand All @@ -94,6 +102,12 @@ impl Default for Config {

// Debug options are always off by default.
debug_force_finished_sync: false,

// Use the default cache dir for the auth cookie.
cookie_dir: default_cache_dir(),

// Enable cookie-based authentication by default.
enable_cookie_auth: true,
}
}
}
34 changes: 28 additions & 6 deletions zebra-rpc/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use std::{fmt, panic, thread::available_parallelism};

use cookie::Cookie;
use http_request_compatibility::With;
use jsonrpc_core::{Compatibility, MetaIoHandler};
use jsonrpc_http_server::{CloseHandle, ServerBuilder};
use tokio::task::JoinHandle;
Expand All @@ -25,14 +27,15 @@ use crate::{
config::Config,
methods::{Rpc, RpcImpl},
server::{
http_request_compatibility::FixHttpRequestMiddleware,
http_request_compatibility::HttpRequestMiddleware,
rpc_call_compatibility::FixRpcResponseMiddleware,
},
};

#[cfg(feature = "getblocktemplate-rpcs")]
use crate::methods::{GetBlockTemplateRpc, GetBlockTemplateRpcImpl};

pub mod cookie;
pub mod http_request_compatibility;
pub mod rpc_call_compatibility;

Expand Down Expand Up @@ -199,13 +202,22 @@ impl RpcServer {
let span = Span::current();
let start_server = move || {
span.in_scope(|| {
let middleware = if config.enable_cookie_auth {
let cookie = Cookie::default();
cookie::write_to_disk(&cookie, &config.cookie_dir)
.expect("Zebra must be able to write the auth cookie to the disk");
HttpRequestMiddleware::default().with(cookie)
} else {
HttpRequestMiddleware::default()
};

// Use a different tokio executor from the rest of Zebra,
// so that large RPCs and any task handling bugs don't impact Zebra.
let server_instance = ServerBuilder::new(io)
.threads(parallel_cpu_threads)
// TODO: disable this security check if we see errors from lightwalletd
//.allowed_hosts(DomainsValidation::Disabled)
.request_middleware(FixHttpRequestMiddleware)
.request_middleware(middleware)
.start_http(&listen_addr)
.expect("Unable to start RPC server");

Expand Down Expand Up @@ -274,29 +286,39 @@ impl RpcServer {
/// This method can be called from within a tokio executor without panicking.
/// But it is blocking, so `shutdown()` should be used instead.
pub fn shutdown_blocking(&self) {
Self::shutdown_blocking_inner(self.close_handle.clone())
Self::shutdown_blocking_inner(self.close_handle.clone(), self.config.clone())
}

/// Shut down this RPC server asynchronously.
/// Returns a task that completes when the server is shut down.
pub fn shutdown(&self) -> JoinHandle<()> {
let close_handle = self.close_handle.clone();

let config = self.config.clone();
let span = Span::current();

tokio::task::spawn_blocking(move || {
span.in_scope(|| Self::shutdown_blocking_inner(close_handle))
span.in_scope(|| Self::shutdown_blocking_inner(close_handle, config))
})
}

/// Shuts down this RPC server using its `close_handle`.
///
/// See `shutdown_blocking()` for details.
fn shutdown_blocking_inner(close_handle: CloseHandle) {
fn shutdown_blocking_inner(close_handle: CloseHandle, config: Config) {
// The server is a blocking task, so it can't run inside a tokio thread.
// See the note at wait_on_server.
let span = Span::current();
let wait_on_shutdown = move || {
span.in_scope(|| {
if config.enable_cookie_auth {
if let Err(err) = cookie::remove_from_disk(&config.cookie_dir) {
warn!(
?err,
"unexpectedly could not remove the rpc auth cookie from the disk"
)
}
}

info!("Stopping RPC server");
close_handle.clone().close();
debug!("Stopped RPC server");
Expand Down
54 changes: 54 additions & 0 deletions zebra-rpc/src/server/cookie.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Cookie-based authentication for the RPC server.

use base64::{engine::general_purpose::URL_SAFE, Engine as _};
use color_eyre::Result;
use rand::RngCore;

use std::{
fs::{remove_file, File},
io::Write,
path::Path,
};

/// The name of the cookie file on the disk
const FILE: &str = ".cookie";

/// If the RPC authentication is enabled, all requests must contain this cookie.
#[derive(Clone, Debug)]
pub struct Cookie(String);

impl Cookie {
/// Checks if the given passwd matches the contents of the cookie.
pub fn authenticate(&self, passwd: String) -> bool {
*passwd == self.0
}
}

impl Default for Cookie {
fn default() -> Self {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);

Self(URL_SAFE.encode(bytes))
}
}

/// Writes the given cookie to the given dir.
pub fn write_to_disk(cookie: &Cookie, dir: &Path) -> Result<()> {
// Create the directory if needed.
std::fs::create_dir_all(dir)?;
File::create(dir.join(FILE))?.write_all(format!("__cookie__:{}", cookie.0).as_bytes())?;

tracing::info!("RPC auth cookie written to disk");

Ok(())
}

/// Removes a cookie from the given dir.
pub fn remove_from_disk(dir: &Path) -> Result<()> {
remove_file(dir.join(FILE))?;

tracing::info!("RPC auth cookie removed from disk");

Ok(())
}
68 changes: 61 additions & 7 deletions zebra-rpc/src/server/http_request_compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,87 @@
//!
//! These fixes are applied at the HTTP level, before the RPC request is parsed.

use base64::{engine::general_purpose::URL_SAFE, Engine as _};
use futures::TryStreamExt;
use jsonrpc_http_server::{
hyper::{body::Bytes, header, Body, Request},
RequestMiddleware, RequestMiddlewareAction,
};

use super::cookie::Cookie;

/// HTTP [`RequestMiddleware`] with compatibility workarounds.
///
/// This middleware makes the following changes to HTTP requests:
///
/// ## Remove `jsonrpc` field in JSON RPC 1.0
/// ### Remove `jsonrpc` field in JSON RPC 1.0
///
/// Removes "jsonrpc: 1.0" fields from requests,
/// because the "jsonrpc" field was only added in JSON-RPC 2.0.
///
/// <http://www.simple-is-better.org/rpc/#differences-between-1-0-and-2-0>
///
/// ## Add missing `content-type` HTTP header
/// ### Add missing `content-type` HTTP header
///
/// Some RPC clients don't include a `content-type` HTTP header.
/// But unlike web browsers, [`jsonrpc_http_server`] does not do content sniffing.
///
/// If there is no `content-type` header, we assume the content is JSON,
/// and let the parser error if we are incorrect.
///
/// ### Authenticate incoming requests
///
/// If the cookie-based RPC authentication is enabled, check that the incoming request contains the
/// authentication cookie.
///
/// This enables compatibility with `zcash-cli`.
///
/// ## Security
///
/// Any user-specified data in RPC requests is hex or base58check encoded.
/// We assume lightwalletd validates data encodings before sending it on to Zebra.
/// So any fixes Zebra performs won't change user-specified data.
#[derive(Copy, Clone, Debug)]
pub struct FixHttpRequestMiddleware;
#[derive(Clone, Debug, Default)]
pub struct HttpRequestMiddleware {
cookie: Option<Cookie>,
}

impl RequestMiddleware for FixHttpRequestMiddleware {
/// A trait for updating an object, consuming it and returning the updated version.
pub trait With<T> {
/// Updates `self` with an instance of type `T` and returns the updated version of `self`.
fn with(self, _: T) -> Self;
}

impl With<Cookie> for HttpRequestMiddleware {
fn with(mut self, cookie: Cookie) -> Self {
self.cookie = Some(cookie);
self
}
}

impl RequestMiddleware for HttpRequestMiddleware {
fn on_request(&self, mut request: Request<Body>) -> RequestMiddlewareAction {
tracing::trace!(?request, "original HTTP request");

// Check if the request is authenticated
if !self.check_credentials(request.headers_mut()) {
let error = jsonrpc_core::Error {
code: jsonrpc_core::ErrorCode::ServerError(401),
message: "unauthenticated method".to_string(),
data: None,
};
return jsonrpc_http_server::Response {
code: jsonrpc_http_server::hyper::StatusCode::from_u16(401)
.expect("hard-coded status code should be valid"),
content_type: header::HeaderValue::from_static("application/json; charset=utf-8"),
content: serde_json::to_string(&jsonrpc_core::Response::from(error, None))
.expect("hard-coded result should serialize"),
}
.into();
}

// Fix the request headers if needed and we can do so.
FixHttpRequestMiddleware::insert_or_replace_content_type_header(request.headers_mut());
HttpRequestMiddleware::insert_or_replace_content_type_header(request.headers_mut());

// Fix the request body
let request = request.map(|body| {
Expand Down Expand Up @@ -80,7 +120,7 @@ impl RequestMiddleware for FixHttpRequestMiddleware {
}
}

impl FixHttpRequestMiddleware {
impl HttpRequestMiddleware {
/// Remove any "jsonrpc: 1.0" fields in `data`, and return the resulting string.
pub fn remove_json_1_fields(data: String) -> String {
// Replace "jsonrpc = 1.0":
Expand Down Expand Up @@ -141,4 +181,18 @@ impl FixHttpRequestMiddleware {
);
}
}

/// Check if the request is authenticated.
pub fn check_credentials(&self, headers: &header::HeaderMap) -> bool {
self.cookie.as_ref().map_or(true, |internal_cookie| {
headers
.get(header::AUTHORIZATION)
.and_then(|auth_header| auth_header.to_str().ok())
.and_then(|auth_header| auth_header.split_whitespace().nth(1))
.and_then(|encoded| URL_SAFE.decode(encoded).ok())
.and_then(|decoded| String::from_utf8(decoded).ok())
.and_then(|request_cookie| request_cookie.split(':').nth(1).map(String::from))
.map_or(false, |passwd| internal_cookie.authenticate(passwd))
})
}
}
8 changes: 8 additions & 0 deletions zebra-rpc/src/server/tests/vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ fn rpc_server_spawn(parallel_cpu_threads: bool) {
indexer_listen_addr: None,
parallel_cpu_threads: if parallel_cpu_threads { 2 } else { 1 },
debug_force_finished_sync: false,
cookie_dir: Default::default(),
enable_cookie_auth: false,
};

let rt = tokio::runtime::Runtime::new().unwrap();
Expand Down Expand Up @@ -134,6 +136,8 @@ fn rpc_server_spawn_unallocated_port(parallel_cpu_threads: bool, do_shutdown: bo
indexer_listen_addr: None,
parallel_cpu_threads: if parallel_cpu_threads { 0 } else { 1 },
debug_force_finished_sync: false,
cookie_dir: Default::default(),
enable_cookie_auth: false,
};

let rt = tokio::runtime::Runtime::new().unwrap();
Expand Down Expand Up @@ -215,6 +219,8 @@ fn rpc_server_spawn_port_conflict() {
indexer_listen_addr: None,
parallel_cpu_threads: 1,
debug_force_finished_sync: false,
cookie_dir: Default::default(),
enable_cookie_auth: false,
};

let rt = tokio::runtime::Runtime::new().unwrap();
Expand Down Expand Up @@ -326,6 +332,8 @@ fn rpc_server_spawn_port_conflict_parallel_auto() {
indexer_listen_addr: None,
parallel_cpu_threads: 2,
debug_force_finished_sync: false,
cookie_dir: Default::default(),
enable_cookie_auth: false,
};

let rt = tokio::runtime::Runtime::new().unwrap();
Expand Down
Loading
Loading