使用类型保证域不变式

2020-12-26 01:54:45

在Rust中实现零生产是对Rust后端开发的自以为是的介绍。您可以在zero2prod.com上预订该书。订阅新闻通讯,以在发布新剧集时得到通知。

我们的新闻通讯API是实时的,托管在Cloud提供商上。我们有一套基本的工具来解决可能出现的问题。有一个公开的端点(POST / subscriptions)可以订阅我们的内容。

但是,我们在这方面走了一些弯路:POST / subscriptions相当...宽松。我们的输入验证极为有限:我们只确保同时提供了名称和电子邮件字段,没有别的。

我们可以添加一个新的集成测试,以通过一些“麻烦的”来探测我们的API。输入:

//! tests / health_check.rs // [...]#[actix_rt :: test] async fn subscription_returns_a_200_when_fields_are_present_but_empty(){//安排let app = spawn_app().await;让client = reqwest :: Client :: new();让test_cases = vec![(" name =& email = ursula_le_guin%40gmail.com&#34 ;,"空名"),(" name = Ursula& email = ","空电子邮件"),(" name = Ursula& email = definitely-not-an-email","无效电子邮件" ),]; for(body,description)in test_cases {//操作let response = client。帖子(& format!(" {} / subscriptions&#34 ;,& app.address))。标头(" Content-Type&#34 ;、" application / x-www-form-urlencoded")。身体(body)。发送().await。期望("无法执行请求。"); //声明assert_eq!(200,response。status()。as_u16(),"当有效载荷为{}时,API没有返回200 OK。&#34 ;, description); }}

不幸的是,新测试通过了。尽管所有这些有效载荷显然都是无效的,但我们的API很乐意接受它们,并返回200 OK。那些麻烦的订户详细信息直接出现在我们的数据库中,随时准备在传递新闻通讯时向我们提出问题。

订阅时事通讯时,我们要求提供两条信息:姓名和电子邮件。本章将重点讨论名称验证:我们应该注意什么?

事实证明,名称很复杂1.弄清楚使名称有效的原因是愚蠢的。请记住,我们选择收集一个名称以在电子邮件的开头行中使用它-我们不需要它来匹配一个人的真实身份,无论这在他们的地理位置中意味着什么。完全没有必要对我们的用户造成不正确或过于规范的验证之苦。

因此,我们可以简单地要求name字段为非空(例如,它必须至少包含一个非空白字符)。

不幸的是,并非互联网上的所有人都是好人。如果有足够的时间,特别是如果我们的时事通讯吸引了人们的注意并取得成功,我们势必会引起恶意访问者的注意。表单和用户输入是主要的攻击目标-如果未正确清除表单和用户输入,则可能使攻击者破坏我们的数据库(SQL注入),在我们的服务器上执行代码,使我们的服务崩溃以及其他令人讨厌的东西。谢谢,但是不,谢谢。

在我们的情况下可能会发生什么?在可能的攻击范围之内,我们该为什么做好准备? 2我们正在建立一封电子邮件通讯,使我们专注于:

拒绝服务-例如尝试取消我们的服务以防止其他人注册。基本上任何在线服务都面临的共同威胁;

网络钓鱼-例如使用我们的服务向受害人发送看似合法的电子邮件,以诱使他们单击某些链接或执行其他操作。

我们是否应该尝试在验证逻辑中应对所有这些威胁?绝对不!但是,最好的做法是采用分层的安全性方法3:通过缓解措施来降低堆栈中多个级别的威胁的风险(例如,输入验证,避免SQL注入的参数化查询,转义电子邮件中的参数化输入等)。如果其中任何一项检查未能通过我们或随后被撤消,我们就不太可能受到攻击。

我们应该始终牢记,软件是鲜活的工件:对系统的整体理解是时间流逝的第一个受害者。第一次写下整个系统时,您就掌握了整个系统,但是下一个开发人员不会使用它-至少从一开始就不会。因此,在应用程序隐晦的角落进行的负载检查可能会消失(例如HTML转义),从而使您容易遭受一类攻击(例如网络钓鱼)。冗余减少了风险。

让我们直言不讳-考虑到我们确定的威胁类别,我们应该对名称进行哪些验证以改善我们的安全状况?我建议:

强制最大长度。在Postgres中,我们使用TEXT作为电子邮件的类型,实际上这是不受限制的-直到磁盘存储开始用完为止。名称有各种形状和形式,但是对于我们大多数用户而言,256个字符应该足够了-4-如果不是,我们将礼貌地请他们输入昵称。

拒绝包含麻烦字符的名称。 /()"< \ {}在URL,SQL查询和HTML片段中非常常见-名称中的用法并不多。5禁止它们会增加SQL注入和网络钓鱼尝试的复杂度。

//! src / routes / subscriptions.rs使用actix_web :: {web,HttpResponse};使用chrono :: Utc;使用sqlx :: PgPool;使用uuid :: Uuid;#[派生(serde :: Deserialize)] pub struct FormData {email:String,name:String,}#[tracing :: instrument(name ="添加新用户" ,跳过(表单,池),字段(电子邮件=%form.email,名称=%form.name))]发布异步fn订阅(表单:web :: Form< FormData&gt ;、池:web :: Data< PgPool> ,)->结果< HttpResponse,HttpResponse> {insert_subscriber(& pool,& form).await。 map_err(| _ | HttpResponse :: InternalServerError()。finish())?; Ok(HttpResponse :: Ok()。finish())} // [...]

//! src / routes / subscriptions.rs //提供`graphemes`方法的扩展特性//在String和`& str`上使用unicode_segmentation :: UnicodeSegmentation; // [...] pub async fn subscription(表单:web :: Form< FormData&gt ;,池:web :: Data< PgPool>)->结果< HttpResponse,HttpResponse> {如果! is_valid_name(& form.name){return Err(HttpResponse :: BadRequest()。finish()); } insert_subscriber(& pool,& form).await。 map_err(| _ | HttpResponse :: InternalServerError()。finish())?; Ok(HttpResponse :: Ok()。finish())} ///如果输入满足我们对订户名称的所有验证约束,则返回true,否则返回false。 pub fn is_valid_name(s:& str)-> bool {//`.trim()`返回输入s上的视图,且不尾随//类似空格的字符。 //`.is_empty`检查视图是否包含任何字符。让is_empty_or_whitespace = s。修剪()。是空的 (); //字形由Unicode标准定义为" user-perceived" //字符:“å”是一个单一的字素,但它由两个字符//(“ a”和“¿”)组成。 // //`graphemes`返回输入s中的字形的迭代器。 //`true`表示我们要使用扩展的字素定义集,//建议的。设is_too_long = s。字素(true)。计数()> 256; //遍历输入`s`中的所有字符,以检查它们是否与//禁止数组中的字符之一匹配。让forbidden_​​characters = [' /&#39 ;,' (&#39 ;,')&#39 ;,' " ',' < ',' > ',' \\&#39 ;、' {&#39 ;,' }'];让contains_forbidden_​​characters = s。字符()。任意(| g | forbidden_​​characters。包含(& g)); //如果违反了我们的任何条件,则返回`false`((is_empty_or_whitespace || is_too_long || contains_forbidden_​​characters)}}

为了成功地编译新函数,我们将必须将unicode-segmentation板条箱添加到我们的依赖项中:

尽管它看起来是一个完美的解决方案(假设我们添加了很多测试),但是像is_valid_name这样的函数给我们带来了错误的安全感。

让我们将注意力转移到insert_subscriber上。让我们想象一下,它要求form.name必须为非空,否则将发生可怕的事情(例如,恐慌!)。

insert_subscriber可以安全地假设form.name将为非空吗?仅通过查看其类型,就不能:form.name是一个String。无法保证其内容。如果您要完整地看一下我们的程序,您可能会说:我们正在检查请求处理程序中它在边缘处是否为非空,因此,我们可以安全地假定form.name每次insert_subscriber都将为非空。被调用。

但是我们不得不从局部方法(让我们看一下这个函数的参数)转变为全局方法(让我们扫描整个代码库)来做出这样的声明。尽管对于像我们这样的小型项目来说可能是可行的,但是检查功能(insert_subscriber)的所有调用站点,以确保事先已执行特定的验证步骤对于大型项目而言是不可行的。

如果我们坚持使用is_valid_name,则唯一可行的方法是再次验证insert_subscriber中的form.name以及需要我们的名称为非空的所有其他函数。这是我们实际上可以确保我们的不变量位于所需位置的唯一方法。

如果insert_subscriber太大而我们必须将其拆分为多个子函数,会发生什么?如果他们需要不变量,则每个变量都必须执行验证以确保其成立。如您所见,这种方法无法扩展。

这里的问题是is_valid_name是一个验证函数:它告诉我们,在程序执行流中的某个时刻,一组条件被验证。但是,有关输入数据中其他结构的信息不会存储在任何地方。它立即丢失。程序的其他部分无法有效地重用它-它们被迫执行另一次时间点检查,从而导致代码库拥挤,每一步都有嘈杂(浪费)的输入检查。

我们需要一个解析函数-一个接受非结构化输入的例程,如果满足一组条件,则返回给我们更结构化的输出,该输出从结构上保证从那时起,我们关心的不变式保持不变。怎么样?

让我们向我们的项目,域中添加一个新模块,并在其中定义一个新的结构SubscriberName:

SubscriberName是一个元组结构-一种新类型,具有一个类型为String的单个(未命名)字段。

SubscriberName是适当的新类型,而不仅仅是别名-它不继承String上可用的任何方法,并且尝试将String分配给类型SubscriberName的变量将触发编译器错误-例如:

错误[E0308]:类型不匹配|让名字:SubscriberName ="字符串" .to_string(); | -------------- ^^^^^^^^^^^^^^^^^^^^^^^ | |预期的结构`SubscriberName`,| |找到struct`std :: string :: String`| | |由于这个预期

根据我们当前的定义,SubscriberName的内部字段是私有的:根据Rust的可见性规则,只能从域模块中的代码中访问它。与往常一样,信任但请验证:如果我们尝试在订阅请求处理程序中构建SubscriberName,会发生什么?

//! src / routes / subscriptions.rs /// pub async fn订阅(形式:web :: Form< FormData&gt ;,池:web :: Data< PgPool>)->结果< HttpResponse,HttpResponse> {let subscription_name = crate :: domain :: SubscriberName(form.name。clone()); /// [...]}

错误[E0603]:元组结构构造函数`SubscriberName`是私有的-> src / routes / subscriptions.rs:25:42 | 25 |让Subscriber_Name = Crate :: domain :: SubscriberName(form.name.clone()); | ^^^^^^^^^^^^^^^ |私有元组struct构造函数| ::: src / domain.rs:1:27 | 1 | pub struct SubscriberName(String); | ------如果|任何字段都是私有的

因此,不可能(就目前而言)在我们的域模块之外构建SubscriberName实例。让我们向SubscriberName添加一个新方法:

//! src / domain.rs使用unicode_segmentation :: UnicodeSegmentation; pub struct SubscriberName(String); impl SubscriberName {///如果输入满足所有///我们对订户名称的验证约束,则返回SubscriberName的实例。 ///否则恐慌。 pub fn parse(s:String)-> SubscriberName {//`.trim()`返回输入`s`上的视图,且不结尾//类似空格的字符。 //`.is_empty`检查视图是否包含任何字符。让is_empty_or_whitespace = s。修剪()。是空的 (); //字形由Unicode标准定义为" user-perceived" //字符:“å”是一个单一的字素,但它由两个字符//(“ a”和“¿”)组成。 // //`graphemes`返回输入s中的字形的迭代器。 //`true`表示我们要使用扩展的字素定义集,//建议的。设is_too_long = s。字素(true)。计数()> 256; //遍历输入`s`中的所有字符,以检查它们是否与//禁止数组中的字符之一匹配。让forbidden_​​characters = [' /&#39 ;,' (&#39 ;,')&#39 ;,' " ',' < ',' > ',' \\&#39 ;、' {&#39 ;,' }'];让contains_forbidden_​​characters = s。字符()。任意(| g | forbidden_​​characters。包含(& g));如果is_empty_or_whitespace || is_too_long || contains_forbidden_​​characters {紧急!(格式!(" {}不是有效的订户名称。&#34 ;, s))}其他{Self(s)}}}

是的,您是对的-这是对is_valid_name中的内容的无耻复制粘贴。

但是有一个关键的区别:返回类型。尽管is_valid_name给了我们一个布尔值,但如果所有检查都成功,则parse方法将返回一个SubscriberName。

还有更多!解析是在域模块之外构建SubscriberName实例的唯一方法-我们在几段之前已经检查了这种情况。因此,我们可以断言,SubscriberName的任何实例都将满足我们所有的验证约束。我们已使SubscriberName实例无法违反这些约束。

//! src / domain.rs // [...] pub struct NewSubscriber {pub email:字符串,pub name:SubscriberName,} pub struct SubscriberName(String); // [...]

如果我们将insert_subscriber更改为接受NewSubscriber类型的参数而不是FormData会发生什么?

使用新签名,我们可以确保new_subscriber.name为非空-不可能通过传递空的订户名称来调用insert_subscriber。我们可以通过查找函数参数类型的定义来得出此结论-我们可以再次进行局部判断,而无需检查函数的所有调用位置。

花点时间了解一下发生的事情:我们从一组需求(所有订户名称必须验证一些约束)开始,我们确定了潜在的陷阱(在调用insert_subscriber之前我们可能忘记了验证输入),并且利用了Rust'的类型系统完全消除了陷阱。通过构造,我们使不正确的使用模式无法表示-无法编译。

这种技术被称为类型驱动开发6。类型驱动开发是一种强大的方法,可以对我们试图在类型系统中建模的域的约束进行编码,依靠编译器来确保它们得到强制执行。我们的编程语言的类型系统表现力越强,就越严格地约束我们的代码,使其仅能够表示在我们正在工作的域中有效的状态。

Rust尚未发明类型驱动的开发-它已经存在了一段时间,特别是在功能编程社区(Haskell,F#,OCaml等)中。鲁斯特(Rust)为您提供足够表达的类型系统,以利用过去几十年来在这些语言中开创的许多设计模式。我们刚刚显示的特定模式通常称为"新型模式"在Rust社区中。

随着实现的进展,我们将涉及类型驱动的开发,但是我强烈邀请您查看本章脚注中提到的一些资源:它们是任何开发人员的宝藏。

我们更改了insert_subscriber的签名,但是我们没有修改正文以匹配新要求-现在就开始做。

//! src / routes / subscriptions.rs使用板条箱:: domain :: {NewSubscriber,SubscriberName}; // [...]#[跟踪::工具([...])] pub异步fn订阅(表单:web :: Form< FormData&gt ;,池:web :: Data< PgPool>)->结果< HttpResponse,HttpResponse> {//`web :: Form`是FormData的包装// // form.0使我们可以访问底层的FormData let new_subscriber = NewSubscriber {电子邮件:form。 0.电子邮件,名称:SubscriberName :: parse(form。0. name),}; insert_subscriber(& pool,& new_subscriber).await。 map_err(| _ | HttpResponse :: InternalServerError()。finish())?; Ok(HttpResponse :: Ok()。finish())}#[跟踪::工具(name ="在数据库中保存新的订户详细信息&#34 ;,跳过(new_subscriber,pool))] pub async fn insert_subscriber(pool:& PgPool,new_subscriber:& NewSubscriber,)->结果<(),sqlx :: Error> {sqlx :: query!(r#"插入订阅(id,电子邮件,名称,subscribed_at)VALUES($ 1,$ 2,$ 3,$ 4)"#,Uuid :: new_v4(),new_subscriber。电子邮件,new_subscriber.name,Utc :: now())。执行(池).await。 map_err(| e | {tracing :: error!("无法执行查询:{:?}&#34 ;, e); e})?好(())}

错误[E0308]:类型不匹配-> src / routes / subscriptions.rs:50:9 | 50 | new_subscriber.name,|预期的^^^^^^^^^^^^^^^& str`,|找到结构`SubscriberName`

我们这里有一个问题:我们没有任何方法可以实际访问封装在SubscriberName中的String值!我们可以将SubscriberName(#)的定义从SubscriberName(String)更改为SubscriberName(pub String),但是我们将失去花了最后两节的所有保证:

其他开发人员将被允许绕过解析并使用任意字符串构建一个SubscriberName

其他开发人员可能仍然选择使用解析来构建SubscriberName,但是他们可以选择稍后将内部值更改为不再满足我们关心的约束的内容

我们可以做得更好-这是利用Rust所有权系统的理想之地!给定结构中的字段,我们可以选择:

impl SubscriberName {pub fn inner(self)-> String {//调用者获取内部字符串,//但不再具有SubscriberName! //这是因为`inner`会按值获取`self`,//根据移动语义self来使用它。 0}}

impl SubscriberName {pub fn inner_mut(& mut self)-> & mut str {//调用者获得对内部字符串的可变引用。 //这样,他们就可以对值本身执行*任意*的更改,有可能破坏我们的不变式! & mut self。 0}} impl SubscriberName {pub fn inner_ref(& self)-> & str {//调用方获得对内部字符串的共享引用。 //这为调用者提供了“只读”访问权限,//他们无法破坏我们的不变式! & 自我。 0}} inner_mut不是我们在这里寻找的东西-对不变式失去控制将等同于使用SubscriberName(pub String)。 inner和inner_ref都适合,bu ......