Merge pull request #671 from teloxide/webhooks_secret_token

Add support for `secret_token` for built-in webhooks

Former-commit-id: c4c3acf742
This commit is contained in:
Waffle Maybe 2022-07-05 00:03:13 +04:00 committed by GitHub
commit 7046b0d8fb
10 changed files with 184 additions and 47 deletions

View file

@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## unreleased
### Added
- Security checks based on `secret_token` param of `set_webhook` to built-in webhooks
### Fixed
- `Dispatcher` no longer "leaks" memory for every inactive user ([PR 657](https://github.com/teloxide/teloxide/pull/657)).

View file

@ -14,7 +14,8 @@ exclude = ["media"]
[features]
default = ["native-tls", "ctrlc_handler", "teloxide-core/default", "auto-send"]
webhooks-axum = ["axum", "tower", "tower-http"]
webhooks = ["rand"]
webhooks-axum = ["webhooks", "axum", "tower", "tower-http"]
sqlite-storage = ["sqlx"]
redis-storage = ["redis"]
@ -56,7 +57,8 @@ full = [
]
[dependencies]
teloxide-core = { version = "0.6.0", default-features = false }
#teloxide-core = { version = "0.6.0", default-features = false }
teloxide-core = { git = "https://github.com/teloxide/teloxide-core", rev = "b13393d", default-features = false }
teloxide-macros = { version = "0.6.2", optional = true }
serde_json = "1.0"
@ -91,6 +93,7 @@ bincode = { version = "1.3", optional = true }
axum = { version = "0.4.8", optional = true }
tower = { version = "0.4.12", optional = true }
tower-http = { version = "0.2.5", features = ["trace"], optional = true }
rand = { version = "0.8.5", optional = true }
[dev-dependencies]
rand = "0.8.3"

View file

@ -27,7 +27,7 @@
/// Implementations of webhook update listeners - an alternative (to
/// [`fn@polling`]) way of receiving updates from telegram.
#[cfg(any(feature = "webhooks-axum"))]
#[cfg(feature = "webhooks")]
pub mod webhooks;
use futures::Stream;

View file

@ -4,6 +4,7 @@ use std::net::SocketAddr;
use crate::{requests::Requester, types::InputFile};
/// Options related to setting up webhooks.
#[must_use]
pub struct Options {
/// Local address to listen to.
pub address: SocketAddr,
@ -42,13 +43,28 @@ pub struct Options {
///
/// Default - false.
pub drop_pending_updates: bool,
/// A secret token to be sent in a header “X-Telegram-Bot-Api-Secret-Token”
/// in every webhook request, 1-256 characters. Only characters `A-Z`,
/// `a-z`, `0-9`, `_` and `-` are allowed. The header is useful to ensure
/// that the request comes from a webhook set by you.
///
/// Default - teloxide will generate a random token.
pub secret_token: Option<String>,
}
impl Options {
/// Construct a new webhook options, see [`Options::address`] and
/// [`Options::url`] for details.
pub fn new(address: SocketAddr, url: url::Url) -> Self {
Self { address, url, certificate: None, max_connections: None, drop_pending_updates: false }
Self {
address,
url,
certificate: None,
max_connections: None,
drop_pending_updates: false,
secret_token: None,
}
}
/// Upload your public key certificate so that the root certificate in use
@ -71,6 +87,32 @@ impl Options {
pub fn drop_pending_updates(self) -> Self {
Self { drop_pending_updates: true, ..self }
}
/// A secret token to be sent in a header “X-Telegram-Bot-Api-Secret-Token”
/// in every webhook request, 1-256 characters. Only characters `A-Z`,
/// `a-z`, `0-9`, `_` and `-` are allowed. The header is useful to ensure
/// that the request comes from a webhook set by you.
///
/// ## Panics
///
/// If the token is invalid.
#[track_caller]
pub fn secret_token(self, token: String) -> Self {
check_secret(token.as_bytes()).expect("Invalid secret token");
Self { secret_token: Some(token), ..self }
}
/// Returns `self.secret_token`, generating a new one if it's `None`.
///
/// After a call to this function `self.secret_token` is always `Some(_)`.
///
/// **Note**: if you leave webhook setup to teloxide, it will automatically
/// generate a secret token. Call this function only if you need to know the
/// secret (for example because you are calling `set_webhook` by yourself).
pub fn get_or_gen_secret_token(&mut self) -> &str {
self.secret_token.get_or_insert_with(gen_secret_token)
}
}
#[cfg(feature = "webhooks-axum")]
@ -91,6 +133,7 @@ where
use crate::requests::Request;
use teloxide_core::requests::HasPayload;
let secret = options.get_or_gen_secret_token().to_owned();
let &mut Options {
ref url, ref mut certificate, max_connections, drop_pending_updates, ..
} = options;
@ -99,12 +142,47 @@ where
req.payload_mut().certificate = certificate.take();
req.payload_mut().max_connections = max_connections;
req.payload_mut().drop_pending_updates = Some(drop_pending_updates);
req.payload_mut().secret_token = Some(secret);
req.send().await?;
Ok(())
}
/// Generates a random string consisting of 32 characters (`a-z`, `A-Z`, `0-9`,
/// `_` and `-`).
fn gen_secret_token() -> String {
use rand::{distributions::Uniform, Rng};
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
const SECRET_LENGTH: usize = 32;
let random = rand::thread_rng()
.sample_iter(Uniform::new(0, CHARSET.len()))
.map(|idx| CHARSET[idx] as char)
.take(SECRET_LENGTH);
let mut secret = String::with_capacity(SECRET_LENGTH);
secret.extend(random);
secret
}
fn check_secret(bytes: &[u8]) -> Result<&[u8], &'static str> {
let len = bytes.len();
if !(1..=256).contains(&len) {
return Err("secret token length must be in range 1..=256");
}
let is_not_supported =
|c: &_| !matches!(c, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-');
if bytes.iter().any(is_not_supported) {
return Err("secret token must only contain of `a-z`, `A-Z`, `0-9`, `_` and `-` characters");
}
Ok(bytes)
}
/// Returns first (`.0`) field from a tuple as a `&mut` reference.
///
/// This hack is needed because there isn't currently a way to easily force a

View file

@ -1,12 +1,14 @@
use std::convert::Infallible;
use std::{convert::Infallible, future::Future, pin::Pin};
use axum::{
extract::{FromRequest, RequestParts},
http::status::StatusCode,
};
use crate::{
dispatching::{
stop_token::{AsyncStopFlag, StopToken},
update_listeners::{
webhooks::{setup_webhook, tuple_first_mut, Options},
UpdateListener,
},
update_listeners::{webhooks::Options, UpdateListener},
},
requests::Requester,
};
@ -105,15 +107,12 @@ where
pub async fn axum_to_router<R>(
bot: R,
mut options: Options,
) -> Result<
(impl UpdateListener<Infallible>, impl std::future::Future<Output = ()> + Send, axum::Router),
R::Err,
>
) -> Result<(impl UpdateListener<Infallible>, impl Future<Output = ()> + Send, axum::Router), R::Err>
where
R: Requester + Send,
<R as Requester>::DeleteWebhook: Send,
{
use crate::requests::Request;
use crate::{dispatching::update_listeners::webhooks::setup_webhook, requests::Request};
use futures::FutureExt;
setup_webhook(&bot, &mut options).await?;
@ -149,12 +148,15 @@ where
/// function.
pub fn axum_no_setup(
options: Options,
) -> (impl UpdateListener<Infallible>, impl std::future::Future<Output = ()>, axum::Router) {
) -> (impl UpdateListener<Infallible>, impl Future<Output = ()>, axum::Router) {
use crate::{
dispatching::{stop_token::AsyncStopToken, update_listeners},
dispatching::{
stop_token::AsyncStopToken,
update_listeners::{self, webhooks::tuple_first_mut},
},
types::Update,
};
use axum::{extract::Extension, http::StatusCode, response::IntoResponse, routing::post};
use axum::{extract::Extension, response::IntoResponse, routing::post};
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tower::ServiceBuilder;
@ -167,9 +169,16 @@ pub fn axum_no_setup(
async fn telegram_request(
input: String,
secret_header: XTelegramBotApiSecretToken,
secret: Extension<Option<String>>,
tx: Extension<CSender>,
flag: Extension<AsyncStopFlag>,
) -> impl IntoResponse {
// FIXME: use constant time comparison here
if secret_header.0.as_deref() != secret.as_deref().map(str::as_bytes) {
return StatusCode::UNAUTHORIZED;
}
let tx = match tx.get() {
None => return StatusCode::SERVICE_UNAVAILABLE,
// Do not process updates after `.stop()` is called even if the server is still
@ -206,6 +215,7 @@ pub fn axum_no_setup(
.layer(TraceLayer::new_for_http())
.layer(Extension(ClosableSender::new(tx)))
.layer(Extension(stop_flag.clone()))
.layer(Extension(options.secret_token))
.into_inner(),
);
@ -245,3 +255,32 @@ impl<T> ClosableSender<T> {
self.origin.write().unwrap().take();
}
}
struct XTelegramBotApiSecretToken(Option<Vec<u8>>);
impl<B> FromRequest<B> for XTelegramBotApiSecretToken {
type Rejection = StatusCode;
fn from_request<'l0, 'at>(
req: &'l0 mut RequestParts<B>,
) -> Pin<Box<dyn Future<Output = Result<Self, Self::Rejection>> + Send + 'at>>
where
'l0: 'at,
Self: 'at,
{
use crate::dispatching::update_listeners::webhooks::check_secret;
let res = req
.headers_mut()
.and_then(|map| map.remove("x-telegram-bot-api-secret-token"))
.map(|header| {
check_secret(header.as_bytes())
.map(<_>::to_owned)
.map_err(|_| StatusCode::BAD_REQUEST)
})
.transpose()
.map(Self);
Box::pin(async { res }) as _
}
}

34
src/features.md Normal file
View file

@ -0,0 +1,34 @@
## Cargo features
| Feature | Description |
|----------------------|------------------------------------------------------------------------------------|
| `webhooks` | Enables general webhook utilities (almost useless on its own) |
| `webhooks-axum` | Enables webhook implementation based on axum framework |
| `macros` | Re-exports macros from [`teloxide-macros`]. |
| `ctrlc_handler` | Enables the [`Dispatcher::setup_ctrlc_handler`] function (**enabled by default**). |
| `auto-send` | Enables the [`AutoSend`](adaptors::AutoSend) bot adaptor (**enabled by default**). |
| `throttle` | Enables the [`Throttle`](adaptors::Throttle) bot adaptor. |
| `cache-me` | Enables the [`CacheMe`](adaptors::CacheMe) bot adaptor. |
| `trace-adaptor` | Enables the [`Trace`](adaptors::Trace) bot adaptor. |
| `erased` | Enables the [`ErasedRequester`](adaptors::ErasedRequester) bot adaptor. |
| `full` | Enables all the features except `nightly`. |
| `nightly` | Enables nightly-only features (see the [teloxide-core features]). |
| `native-tls` | Enables the [`native-tls`] TLS implementation (**enabled by default**). |
| `rustls` | Enables the [`rustls`] TLS implementation. |
| `redis-storage` | Enables the [Redis] storage support for dialogues. |
| `sqlite-storage` | Enables the [Sqlite] storage support for dialogues. |
| `cbor-serializer` | Enables the [CBOR] serializer for dialogues. |
| `bincode-serializer` | Enables the [Bincode] serializer for dialogues. |
[Redis]: https://redis.io/
[Sqlite]: https://www.sqlite.org/
[CBOR]: https://en.wikipedia.org/wiki/CBOR
[Bincode]: https://github.com/servo/bincode
[`teloxide-macros`]: https://github.com/teloxide/teloxide-macros
[`native-tls`]: https://docs.rs/native-tls
[`rustls`]: https://docs.rs/rustls
[`teloxide::utils::UpState`]: utils::UpState
[teloxide-core features]: https://docs.rs/teloxide-core/latest/teloxide_core/#cargo-features
[`Dispatcher::setup_ctrlc_handler`]: dispatching::Dispatcher::setup_ctrlc_handler

View file

@ -1,29 +0,0 @@
## Cargo features
| Feature | Description |
|----------|----------|
| `redis-storage` | Enables the [Redis] storage support for dialogues.|
| `sqlite-storage` | Enables the [Sqlite] storage support for dialogues. |
| `cbor-serializer` | Enables the [CBOR] serializer for dialogues. |
| `bincode-serializer` | Enables the [Bincode] serializer for dialogues. |
| `macros` | Re-exports macros from [`teloxide-macros`]. |
| `native-tls` | Enables the [`native-tls`] TLS implementation (enabled by default). |
| `rustls` | Enables the [`rustls`] TLS implementation. |
| `ctrlc_handler` | Enables the [`Dispatcher::setup_ctrlc_handler`](dispatching::Dispatcher::setup_ctrlc_handler) function. |
| `auto-send` | Enables the [`AutoSend`](adaptors::AutoSend) bot adaptor. |
| `throttle` | Enables the [`Throttle`](adaptors::Throttle) bot adaptor. |
| `cache-me` | Enables the [`CacheMe`](adaptors::CacheMe) bot adaptor. |
| `trace-adaptor` | Enables the [`Trace`](adaptors::Trace) bot adaptor. |
| `erased` | Enables the [`ErasedRequester`](adaptors::ErasedRequester) bot adaptor. |
| `full` | Enables all the features except `nightly`. |
| `nightly` | Enables nightly-only features (see the [teloxide-core features]). |
[Redis]: https://redis.io/
[Sqlite]: https://www.sqlite.org/
[CBOR]: https://en.wikipedia.org/wiki/CBOR
[Bincode]: https://github.com/servo/bincode
[`teloxide-macros`]: https://github.com/teloxide/teloxide-macros
[`native-tls`]: https://docs.rs/native-tls
[`rustls`]: https://docs.rs/rustls
[`teloxide::utils::UpState`]: utils::UpState
[teloxide-core features]: https://docs.rs/teloxide-core/latest/teloxide_core/#cargo-features

View file

@ -38,7 +38,7 @@
// [1]: https://github.com/rust-lang/rustfmt/issues/4210
// [2]: https://github.com/rust-lang/rustfmt/issues/4787
// [3]: https://github.com/rust-lang/rust/issues/82768#issuecomment-803935643
#![cfg_attr(feature = "nightly", cfg_attr(feature = "nightly", doc = include_str!("features.txt")))]
#![cfg_attr(feature = "nightly", cfg_attr(feature = "nightly", doc = include_str!("features.md")))]
// https://github.com/teloxide/teloxide/raw/master/logo.svg doesn't work in html_logo_url, I don't know why.
#![doc(
html_logo_url = "https://github.com/teloxide/teloxide/raw/master/ICON.png",

View file

@ -191,6 +191,8 @@ mod tests {
last_name: None,
username: Some("abcd".to_string()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
assert_eq!(user_mention_or_link(&user_with_username), "@abcd");
let user_without_username = User {
@ -200,6 +202,8 @@ mod tests {
last_name: None,
username: None,
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
assert_eq!(
user_mention_or_link(&user_without_username),

View file

@ -240,6 +240,8 @@ mod tests {
last_name: None,
username: Some("abcd".to_string()),
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
assert_eq!(user_mention_or_link(&user_with_username), "@abcd");
let user_without_username = User {
@ -249,6 +251,8 @@ mod tests {
last_name: None,
username: None,
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
};
assert_eq!(
user_mention_or_link(&user_without_username),