diff --git a/source/_posts/2018-02-08-domain-validation-vs-form-validation.md b/source/_posts/2018-02-08-domain-validation-vs-form-validation.md new file mode 100644 index 00000000..257c72a5 --- /dev/null +++ b/source/_posts/2018-02-08-domain-validation-vs-form-validation.md @@ -0,0 +1,643 @@ +/--- +layout: post +title: Domain Validation vs Form Validation +category: Security +tags: ["PHP", "Security", "API Design", "DDD", "Hexagonal Design"] +year: 2018 +month: 2 +day: 8 +published: true +summary: @TODO Domain Validation is a required execution path for our code, whereas Form Validation is not: let's figure out why +description: @TODO +tweet: @TODO +--- +

+ Yesterday, I had a + quite chatty discussion + about how to approach server-side validation in HTTP-based applications. +
+ The focus of the discussion was around SPA design, + but this article will try to keep things even more simplified. +

+ +

+ DISCLAIMER: + this article is in no way suggesting to remove server-side validation. + If your customers can open a debugger and modify information that is directly used by your core + processes, you already lose. There is no such thing as "validating in the client". +

+ +

+ For the sake of simplicity, we will design the familiar authentication domain in our examples: +

+ + + +

+ In such a domain, we will likely design our core logic with following + repository and aggregate: +

+ +~~~php + + INFO: + If you never designed code this way, you may want to read about + Aggregates and External Context Interactions +

+ +

+ This is the kernel of our extremely simplistic domain. + Still, some concepts are under-specified: +

+ + + +

+ The answers to these questions is quite obvious to developers that implemented this same + logic multiple times over the years, but they are still assumptions in our mental model. +
+ Let's instead write them down: +

+ +~~~php + + That's better! We should also define some invariants: +

+ +~~~php + 200) { + throw EmailAddressTooLong::fromString($email); + } + + $instance = new self(); + + $instance->email = $email; + + return $instance; + } +} +~~~ + +

+ That solved a few problems, as we now know that we only accept sensible + email addresses, and we also made it clear that in our system, the concept of username + and email somehow overlap. +

+ +~~~php +password = $password; + + return $instance; + } +} +~~~ + +

+ Our system now rejects short passwords completely: that is a security constraint that + we really need to define to prevent empty strings flying around and causing unexpected + chaos. +

+ +

+ WARNING: + please do not add silly password policies other than a minimum length: it will lead + simply lead to people typing in horrors abc123!!. +

+ +

+ WARNING: + do NOT add the password to the thrown exception details, as exceptions + are usually to be logged. +

+ +

+ We can now adapt our User aggregate to rely on these invariants: +

+ +~~~php + + So far, we added invariants for our values, but what about + our context? Can two User instances with the same Username + exist within our system? Absolutely not! We can fix this by combining a read model + with our aggregate named constructor: +

+ +~~~php +username = $username; + $instance->passwordHash = $hashPassword($password); + + return $instance; + } + + public function authenticate( + PlainTextPassword $password, + VerifyPassword $verifyPassword + ) : bool { + return $verifyPassword($password, $this->passwordHash); + } +} +~~~ + +

+ That's it: that's our simplistic authentication system, and you can use it in a CLI + or HTTP application without any particular added validation needed. We will implement + a naive application through PSR-15 request handlers. +

+ +

+ Registration is as simple as this: +

+ +~~~php +users = $users; + $this->userExists = $userExists; + $this->hashPassword = $hashPassword; + } + + public function handle(ServerRequestInterface $request) : ResponseInterface + { + Assert::postRequest($request); + + $postData = $request->getParsedBody(); + + $this->users->store(User::register( + Username::fromEmailAddress($postData['email']), + PlainTextPassword::fromPlainText($postData['password']), + $this->hashPassword, + $this->userExists + )); + + return new TextResponse('Registered! We sent you some spam, and subscribed you to our 10000 SEM campaigns'); + } +} +~~~ + +

+ Login is also straightforward: +

+ +~~~php +users = $users; + $this->userExists = $userExists; + $this->verifyPassword = $verifyPassword; + $this->sessionHelper = $sessionHelper; + } + + public function handle(ServerRequestInterface $request) : ResponseInterface + { + Assert::postRequest($request); + + $postData = $request->getParsedBody(); + + $username = Username::fromEmailAddress($postData['email']), + + if (! $this->userExists($username)) { + return new RedirectResponse('/login?failed=true', 401); + } + + $user = $this->users->get($username); + + if (! $user->authenticate( + PlainTextPassword::fromPlainText($postData['password']), + $this->verifyPassword + )) { + return new RedirectResponse('/login?failed=true', 401); + } + + return $this->sessionHelper->addIdentityTo(new RedirectResponse('/dashboard', 200), $username); + } +} +~~~ + +

The point

+ +

+ The point I am trying to make here is that we wrote an entire authentication + component with no validation components involved. +

+ +

+ Try logging in with an invalid email: you will get a 500 error. +

+ +

+ Try logging in with an invalid password: you will get a 500 error. +

+ +

+ Try registering with an already existing user: you will get a 500 error. +

+ +

+ Is this always desirable? Of course not, but for simple value validation, plain HTML + is more than sufficient: +

+ +~~~php + + All ur privacy are belong to us + +
+ + + +
+ + +HTML + ); + } +} +~~~ + +

+ And that is sufficient. +

+ +

+ There is one thing we can't do in the frontend without some elaborate JS contraption, + and since we don't want an elaborate JS contraption (nobody wants it, except those + that seek the way to produce more work), we can simplify the RegisterAction + by adding a check in it: +

+ +~~~php +users = $users; + $this->userExists = $userExists; + $this->hashPassword = $hashPassword; + } + + public function handle(ServerRequestInterface $request) : ResponseInterface + { + Assert::postRequest($request); + + $postData = $request->getParsedBody(); + + $username = Username::fromEmailAddress($postData['email']); + + if (($this->userExists)($username)) { // added this to make this "explode less" + return new RedirectResponse('/register?username_taken=true', 422); + } + + $this->users->store(User::register( + Username::fromEmailAddress($postData['email']), + PlainTextPassword::fromPlainText($postData['password']), + $this->hashPassword, + $this->userExists + )); + + return new TextResponse('Registered! We sent you some spam, and subscribed you to our 10000 SEM campaigns'); + } +} +~~~ + +

+ I'll skip over how login would look like, because it would be the exact same thing +

+ +

Domain validation over validation components

+ +

+ As you've seen, the core domain was fully responsible for guaranteeing the security + constraints. This should be the case in every business domain. +

+ +

+ What I usually do see in applications is a bunch of under-designed and complex code + that glues together zendframework/zend-form or symfony/form + into a nightmare of hidden and pseudo-magic constraints that implicitly leak into + the logic of the application. +

+ +

+ You know the drill: upgrade a dependency, and suddenly your core domain no longer works. + Also, you cannot expose this domain through different endpoints (command line interface, + worker queue, other cooperating domains, HTTP APIs, etc.) without having to replicate + the entire constraints and validation in outer layers, leaving a lot of juicy information + be found by (hopefully) pentesters. +

+ +

+ Therefore, if it is a decision, put it in the domain. +

+ +

+ This reasoning does not exclude that you can add server-side form validation to + make UX better, but you should strive for + simplicity and for putting constraints in the domain, as that is your last and most + important defense line against invalid, corrupted or malicious information. +

+ +

+ Everything else is pretty much secondary and "nice-to-have". +

+ +

Advantages and disadvantages

+ +

+ Putting data integrity and contextual validation in the domain has a few nice effects + that do picking this way an absolute no-brainer: +

+ + + +

+ The main disadvantage of using this approach is that, when designing an API to be exposed + over the network, all failures require manual translation into proper error messages. +
+ Differentiating between user errors and system errors is not always possible, and displaying + the exception message cannot be done by default, because some degree of sensitive data may + be contained in it. +
+ Therefore, when building a network-facing API layer designed for customers, you will have + to plan an additional development phase in which you are covering all the unhappy paths. +
+ The good part of that is that this is completely optional for an + MVP. +

+ +

Domain first, everything else afterwards

+ +

+ To conclude, you should almost always design your applications with following priority in mind: +

+ +
    +
  1. + Design data types upfront, assign them a type, enforce the type invariants +
  2. +
  3. + Design contextual validation in the domain, as part of the business logic. + It must be readable and explicit +
  4. +
  5. + Design the entry point to your domain logic (HTTP controllers/actions/middleware/etc), + let the unhappy paths crash spectacularly. A 500 error is good enough, + and it will help you (from the logs) in figuring out what is important and what is not. +
  6. +
  7. + Add validation constraints to the frontend. The simplest possible approach + is sufficient. In this post, I used HTML5 form validation, and that's OK. +
  8. +
  9. + Add server-side input validation where needed, where you'd prefer a nice error message + over a crash. +
  10. +