Skip to content

Commit

Permalink
feat(rpc): Cookie auth system for the RPC endpoint (#8900)
Browse files Browse the repository at this point in the history
* add a cookie auth system for the rpc endpoint

* fix rand import

* fixes based on cookie method research

* add and use `cookie_dir` config, rpc client changes

* add missing dependency

* add a enable_cookie auth option to config and use it in all tests

* get rid of the unauthenticated method

* change config in qa python tests to run unauthenticated

* change return types in cookie methods

* change comment

* fix(rpc): Refactor the cookie-based RPC authentication (#8940)

* Refactor the cookie-based RPC authentication

* Rephrase docs

* Apply suggestions from code review

Co-authored-by: Arya <[email protected]>

---------

Co-authored-by: Arya <[email protected]>

* clippy

---------

Co-authored-by: Marek <[email protected]>
Co-authored-by: Arya <[email protected]>
  • Loading branch information
3 people authored Oct 22, 2024
1 parent 47b7614 commit b1ffc89
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 17 deletions.
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

0 comments on commit b1ffc89

Please sign in to comment.