This blog post is adapted from a talk given by Joe Kutner at Devoxx 2018 titled "10 Mistakes Hackers Want You to Make."
Building self-defending applications and services is no longer aspirational--it’s required. Applying security patches, handling passwords correctly, sanitizing inputs, and properly encoding output is now table stakes. Our attackers keep getting better, and so must we.
In this blog post, we'll take a look at several commonly overlooked ways to secure your web apps. Many of the examples provided will be specific to Java, but any modern programming language will have equivalent tactics. Please feel free to share methods for other languages in the comments.
1. Ensure dependencies are up-to-date
Every year, OWASP, a group of security experts and researchers, publishes a list of the common application security risks to watch out for. One of the more common issues they've found is the use of dependencies with known vulnerabilities. Once a known CVE is published, many open source maintainers and contributors make a concentrated effort to released patched updates to popular frameworks and libraries. But one report found that more than 70% of exploited applications are due to outdated dependencies.
To ensure that your projects are relying on the latest and greatest packages, and automating your dependency management is recommended.
With Maven, you can use the Maven Versions Plugin, which automatically updates your pom.xml
to use the newest packages. In Ruby, the bundle update
command does something similar. You could incorporate these tools into your CI/CD process, and have a test outright fail if a dependency is outdated, thus forcing you to upgrade a package before your app can be deployed.
A more proactive approach might be to incorporate a tool that automatically monitors your dependencies for you, such as Snyk. Rather than running a check when code is modified (which could expose your app to a vulnerability for weeks or months, if it's infrequently updated), Snyk monitors your dependencies and compares them with a known list of vulnerabilities mapped to dependencies. If a problem is identified, they'll alert you with a report identifying which dependencies are outdated and which version contains a patched fix. Snyk is also free to try on Heroku.
2. Explicitly declare acceptable user payloads
All too often, web applications will accept nearly anything a user submits through a form or an API. For example, a user may attempt to create an account with a password containing over a thousand characters. When numerous requests like this are sent, it is possible the server will crash under the intense computation necessary to encrypt them.
One way to mitigate attacks like this is by implementing database-level constraints. Columns should have a maximum size defined, or your data model should refuse to accept NULL
values. While placing these restrictions on the database is always a good idea, it can be considered too "low level," as certain attacks can be exploited fair earlier in the request cycle.
If your application is exposed to the Internet, every vulnerability is just one curl
call away. In one example with the Jackson data-binding library, a simple JSON payload was able to execute arbitrary code, once the request was received by the server.
By providing an explicit list of expected inputs , you can ensure that your application is only operating on data that it knows will be coming, and ignoring (or politely erroring) on everything else. If your application accepts JSON, perhaps as part of an API, implementing JSON schemas are an excellent way to model acceptable requests. For example, if an endpoint takes two string
fields named firstName
and lastName
, and one integer named age
, a JSON schema to validate user-provided requests might look like this:
{
"title": "Person",
"type": "object",
"properties": {
"firstName": {
"type": "string",
"description": "The person's first name."
},
"lastName": {
"type": "string",
"description": "The person's last name."
},
"age": {
"description": "Age in years which must be equal to or greater than zero.",
"type": "integer",
"minimum": 1
}
}
}
By stating the valid types, you can prevent unexpected issues from occurring if a user decides to send integers for firstName
or negative numbers for age
.
In addition to the request body, you should also check request headers and query parameters, which can be similarly exploitable.
3. Assert safe regular expressions
Regular expressions are both a boon and curse for every developer. They can make pattern matching on strings an easy task, but a poorly crafted regular expression can also bring an application down.
Consider a simple pattern like this one: (a|aa)+
. While it looks innocuous, the |
("or") combined with the +
operator can take a catastrophically long time to match against a string such as "aaaaaaaaaaaaaaaaaaaaaaaa!"
. Malicious users could cause a denial-of-service attack on your system by submitting a particularly complicated (yet still technically "valid") text. (A similar issue affected the Node.js community several years ago.)
Validating your regular expressions will ensure that they are not susceptible to this type of ReDoS attack. One tool you can use is saferegex, a command-line Java utility that will report on the likelihood of a regular expressions causing a problem:
$ java -jar target/saferegex.jar "(a|aa)+"
Testing: (a|aa)+
More than 10000 samples found.
***
This expression is vulnerable.
Sample input: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
4. Prevent abusive requests
Building a popular application involves more than just adding desired features. Your site will also need to handle the amount of traffic it receives as it grows. Even if every part of your application is secure, bad actors who repeatedly hammer your servers could succeed in bringing them down.
To ensure uptime for your users, you should throttle aggressive clients. This can be done in a few different ways, like limiting requests by IP address or user agent.
A better implementation would be to use a library that takes advantage of a token-bucket algorithm. Bucket4j is one such library. Incoming requests are grouped by a variety of properties into individual "buckets," and those buckets can in turn be throttled or blacklisted entirely. By classifying which requests are acceptable and which aren't, you'll be able to better handle sudden bursts of traffic.
5. Align your code to be secure-first
Often, in the midst of a particularly frustrating bug, we may in our haste implement a solution pilfered from some corner of the Internet. While finally solving a problem may come as a much-needed relief, it's always worth triple-checking that you haven't inadvertently introduced a security issue.
A few years ago, researchers found that a majority of acceptable answers on StackOverflow contained insecure flaws. Code that works does not mean that code is secure. Even if a snippet works in the short-term, it's important to be absolutely certain that it is safe to use.
6. Store credentials outside your codebase
We all know (hopefully!) that divulging your personal password can be a catastrophic mistake. In any sufficiently complicated application, there can be a dozen different tokens and passwords to manage: your database username and password, tokens to authenticate to New Relic, or DataDog, or Redis...
Keep your application's configuration separate from your code. Even if your repository is private, embedding plaintext credentials is never a good idea. A disgruntled employee who shouldn't have access could steal the token to impersonate users. To ensure that your project is safe, you should be confident that, if the code became open source at any moment, none of your credentials would be compromised.
Store your secrets in environment variables. A library like dotenv can seamlessly load and make use of these variables, provided they're accessible in a secure location. Another option is to use a product like Hashicorp Vault, which allows your application to manage secrets through a configurable CLI.
7. Deny HTTP requests
Unless you have a very specific use case, you should disable HTTP connections to your server. An HTTPS connection ensures that data between the client and the server is encrypted, thus prohibiting person-in-the-middle snooping attacks. Most major browsers default to HTTPS connections by default, and services such as Let's Encrypt make it easier than ever to obtain an SSL certificate for your application.
If you need to support HTTP locally or between a proxy and your web server, you can configure your server to only accepts clients whose X-Forwarded-Proto
request header is set to https
. Depending on your setup, this may also be configurable outside of your code via an NGINX or Apache configuration.
8. Enable certificate checking
Sometimes, your application might need to make a call to an external provider. Similar to the suggestion above, you should enable certificate checking for outgoing connections. This ensures that communication to third-party APIs or services are also secured via HTTPS. Note that if a third-party website has a misconfigured certificate, it can cause errors when your application tries to connect. You might be tempted to just disable certificate checking to ensure that the application "just works," but this is a very insecure move that puts your users' data at risk.
You can use a library like EnvKeyStore to facilitate the storage of keys and certificates. Similar to dotenv, EnvKeyStore asks that you keep set a certificate PEM be to an environment variable. You can then use this PEM as the default checker for any outgoing client requests. For example:
KeyStore ts = EnvKeyStore.createWithRandomPassword("TRUSTED_CERT").keyStore();
String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
tmf.init(ts);
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, tmf.getTrustManagers(), new SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
String urlStr = "https://ssl.selfsignedwebsite.xyz";
URL url = new URL(urlStr);
HttpsURLConnection con = (HttpsURLConnection)url.openConnection();
con.setDoInput(true);
con.setRequestMethod("GET");
con.getInputStream().close();
9. Log and monitor suspicious behavior
Many applications only log critical failures, like unexpected server errors. But even behavior we have accounted for can be used as an attack vector. In those cases, it's imperative to log any sensitive action. An example of some behavior to log includes:
- Successful and unsuccessful logins
- Password resets
- Changes to access levels
- Authorization failures
In many of these cases, a user who is repeatedly generating an error may be a sign of a malicious attacker attempting to take over an account.
In order to separate these events from other errors, we recommend prefixing your log statements with phrases such as SECURITY_SUCCESS
, SECURITY_FAILURE
, and SECURITY_AUDIT
. This way, you can easily filter on specific categories of authorization failures, should the need arise. Keep in mind that sensitive information, such as session IDs or passwords, should not be logged, as they will be stored in plaintext.
Another tactic to employ is to add an intrusion detection system. OWASP has a project called AppSensor, which provides a framework to detect and respond to potential attacks in an automated way. This works by adding an agent to your web application which sends events to your external AppSensor service. AppSensor will analyze those events, and, if it determines malicious behavior, AppSensor will respond back to the web app with a payload of information identifying what is going on. The application can then determine which action to take.
Take a look at the list of AppSensor detection points to potentially identify where your application needs improved intrusion detection.
10. Limit your own access
We all make mistakes. Although we ask users of our applications to behave with security in mind, we also need to practice good security hygiene. Some common sense actions that everyone should take include:
- Using two-factor authentication wherever possible
- Locking your computer screen any time you're not at your workstation
- Implementing unique passwords across accounts and services, and using a password manager
Final thoughts
Security is hard, not because it's difficult to implement, but because it's difficult to identify how to be secure. When writing code it's far easier to achieve the intended functionality but much harder to conceive the unintended functionality, which is where your security issues will arise. In addition, there are many different kinds of security that comprise a healthy security posture: network security, platform security, physical security, and so on.
The ten ways you just learned to protect yourself are good starting points to ensure that your application security is top-notch. If you want to continue learning about web application security, you can check out the OWASP Getting Started guide for more information.
Stay safe, and again share any security tips you have in the comments—for Java or any other language!