The greatest risk to the security of a web application is when it either accepts user input and processes it or renders data in a comprehensible format (HTML, JSON, etc.) to the user.
For software written in PHP, this usually means generating and validating HTTP form inputs. The specific risk factors aren’t exactly a mystery.
If your goal is to prevent insecure software from being developed, the worst thing you can do is to dictate a bunch of increasingly arcane-sounding requirements to developers working against a tight deadline then shame them when they fuck it up.
A much better strategy is to give developers a tool, with minimal fuss and dependencies, that’s tuned for security out-of-the-box and always does The Right Thing for them.
Even better is if you introduce it as a write-less, do-more tool that saves developers time and frustration. This incentivizes them to use it over a quick-and-dirty, error-prone, artisanal approach to solving the same problems.
And thus, I made Cupcake.
Here’s a working example of a login form with Cupcake. This includes an optional “remember me?” checkbox.
<?php declare(strict_types=1); use Soatok\Cupcake\Blends\{ CheckboxWithLabel, DivWithPurifiedHtml }; use Soatok\Cupcake\Form; use Soatok\Cupcake\Ingredients\{ Grouping, Input\Text, Input\Password }; // First, let's instantiate our form: $form = (new Form()) ->append( (new DivWithPurifiedHtml('')) ->setId('form-feedback') )->append( (new Grouping()) ->setBeforeEach('<div class="form-row">') ->setAfterEach('</div>') ->append( (new Text('username')) ->setId('form1-username') ->setRequired(true) ->setPattern('^[a-z0-9_\-]{2,}$') ) ->createAndPrependLabel('Username') ->append( (new Password('password')) ->setId('form1-password') ->setRequired(true) )->createAndPrependLabel('Password') )->append( CheckboxWithLabel::create( 'remember-me', '1', 'form1-remember-me', 'Remember me on this computer?' ) ); // This is optional; by default, it includes at the time // of form rendering. This can cause issues with the Cookie-backed // Anti-CSRF implementation. Custom implementations may be unaffected. $form->finalizeCsrfElement(); return $form;
This looks mildly verbose, but it gives you very explicit control over the rendered form.
In actuality, you’d want to use Cupcake to render forms programmatically rather than manually writing boilerplate code like this.
Input validation with Cupcake looks like this:
<?php declare(strict_types=1); use ParagonIE\Ionizer\InvalidDataException; use Soatok\Cupcake\Blends\DivWithPurifiedHtml; use Soatok\Cupcake\Form; /** * @var callable $callback (user-defined) * @var Form $form (see previous snippet) * * If you want, copy the definition of $form from the previous snippet * to the current snippet, right below this docblock. Or you can just * do this: * * $form = require "other-snippet.php"; */ // Next, let's process data: if (!empty($_POST)) { try { $postData = $form->getValidFormInput($_POST); // Pass the validated post data to another function $callback($postData); exit; } catch (InvalidDataException $ex) { /** @var DivWithPurifiedHtml $el */ $el = $form->getChildById('form-feedback'); $el->setContents($ex->getMessage()); // Populate the form elements with $_POST data $form->populateUserInput($_POST); } } // To print the form into a web page, just print it: echo '<!DOCCTYPE html><html><body>'; echo $form; echo '</body></html>';
Cupcake’s first priority is security!
Remember above, when we set some rules about the username field?
/*~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~*/ ->append( (new Text('username')) ->setId('form1-username') ->setRequired(true) ->setPattern('^[a-z0-9_\-]{2,}$') ) ->createAndPrependLabel('Username') /*~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~*/
The field was marked as required
and had an input validation pattern
(regular expression) attached. This will populate the relevant HTML attributes in the rendered form.
But most importantly, these rules are enforced server-side too.
/*~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~*/ try { $postData = $form->getValidFormInput($_POST); // Pass the validated post data to another function $callback($postData); exit; } catch (InvalidDataException $ex) { /*~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~8<~*/
If you provided a username less than 2 characters long, or containing a character outside the permitted range, Cupcake will throw an InvalidDataException
.
If you want to handle it gracefully, you can catch this exception. Otherwise, you can simply let it stop your PHP application from continuing with invalid data.
Cupcake prevents you from ever recreating Twitter’s mistakes.
Wordplay! The German word for a cupcake mold is förmchen.
Cupcake uses context-aware escaping rules to prevent attributes from being escaped, and arbitrary HTML is processed with HTMLPurifier to prevent invalid HTML (which includes XSS exploits).
More importantly, Cupcake leverages Psalm’s taint analysis feature to help developers recognize insecure uses of the RawHtmlBlock
class.
As discussed in the previous section, Cupcake uses Ionizer to ensure all form inputs adhere to a strict contract; which encompasses both type-safety and input validation. This prevents all sorts of invalid inputs from being accepted.
Ionizer’s type strictness turns out to be sufficient for preventing NoSQL Injection attacks.
However, you should not rely on this for stopping SQL Injection. Instead, use parametrized queries (also known as prepared statements) to prevent user input from mangling your query strings.
You bet! The default implementation checks a form value against a cryptographically secure random value stored in a SameSite cookie.
If the HTTPS Request carrying form data wasn’t initiated by the same site, it won’t include the cookie value. If the cookie value is absent, it aborts. If the cookie value doesn’t match the one provided by the request body, it aborts. This cookie is only ever sent over secure connections.
Let’s say you have a Cupcake container (e.g. Form
object), and you want to access a specific child element (or container), and you only have its ID.
$element = $form->getChildById('element-id-goes-here', true); // The second parameter enables recursive search (disabled by default)
It’s that easy.
If you’d like to repeat user data in order to inform the user that the data they provided was not accepted, Cupcake makes this easy too:
$form->populateUserInput($_POST);
This is documented on GitHub.
Cupcake is currently alpha software, and it only runs on PHP 8. It’s not quite ready for production yet.
In the coming months, I plan to flesh out the documentation a bit better (with plenty of examples) and add more robust security testing.
Until then, feel free to hack on the code and send any changes you think are important.