AppSec Project - Chapter 3, Enhancing Security
Introduction
In today’s article, we will focus on implementing several enhancements to improve the overall security of our web application.
While most of these changes do not address specific vulnerabilities, they play a crucial role in mitigating potential future issues and follow established best coding practices.
Secret Key
Our first priority is addressing the secret key used for generating session tokens assigned to user sessions.
app.config['SECRET_KEY'] = 'secretkey'
Currently, there are two main issues. The first is that the key is overly simplistic, making it susceptible to brute force attacks. This weakness can lead to serious vulnerabilities such as account takeovers.
Tools like flask-unsign can be used to identify this key, and once obtained, attackers can craft session tokens to impersonate other users.
To demonstrate, we can use the following command to uncover the key:
flask-unsign --unsign --cookie "cookie"
Once the key is discovered, we can craft a session token for user ID 1 and gain unauthorized access to their account.
flask-unsign --sign --cookie "{'_user_id': '1', 'csrf_token': 'CSRF_TOKEN'}" --secret "secretkey"
Then replace the session cookie and access the administrator profile.
To enhance the security of our secret key and protect against successful brute force attacks, we should use a longer, more complex key.
app.config['SECRET_KEY'] = 'nowoursecretkeyisstrongenoughmyfriends!'
The second improvement involves addressing hardcoded sensitive information within the source code. Best practices in secure coding recommend storing such data in safer locations, such as environment variables, to reduce the risk of exposure.
To achieve this, we can use the following approach on __init__.py
:
from dotenv import load_dotenv
import os
...
load_dotenv()
# Secret Key will be used to generate the tokens
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY")
Passwords
When it comes to password handling, there are crucial improvements we need to implement.
The first step is hashing passwords before storing them, which is essential for protecting user data. Storing passwords in plain text exposes users to significant risks in case of a data breach.
For instance, in the SQL Injection scenario we discussed in previous chapters, hashed passwords would prevent attackers from easily obtaining clear-text credentials.
Moreover, a well-implemented hashing mechanism ensures that even if passwords are compromised, they would be difficult, if not impossible, to crack within a reasonable timeframe.
Passwords in plain text in the database:
To hash passwords securely, we can use the generate_password_hash
function from Werkzeug, which is specifically designed for this purpose.
else:
hashed_password = generate_password_hash(password1)
new_user = User(email=email, username=username, password=hashed_password)
db.session.add(new_user)
db.session.commit()
As shown with user 3 below, the password is now properly hashed in the database.
Next, we need to update the relevant routes to handle password verification, including the login and password change features. This ensures that password comparisons are performed correctly using hashed values.
/reset-password/<token>
else:
user = token_entry.user
user.password = generate_password_hash(new_password)
/change_password
else:
# Update the password
current_user.password = generate_password_hash(new_password)
db.session.commit()
flash('Password updated successfully', 'success')
/login
if user:
if check_password_hash(user.password, password):
flash('Logged in succesfully!', category='success')
Password Policy
Another important consideration is improving our password policy. Currently, allowing passwords with a minimum of 5 characters is insufficient for strong security.
The standard recommendation is to enforce a policy with a minimum of 8 characters, including at least one uppercase letter, one lowercase letter, one special symbol, and numbers. However, given that our application does not handle highly sensitive data, we can opt for a slightly relaxed rule: a minimum of 8 characters with a mix of letters and numbers. While not the strongest setup, it is sufficient for this context.
To implement this, we can create a function that enforces the password policy, as it will be needed in multiple routes, such as registration and password change.
def is_valid_password(password):
if len(password) < 8:
return False
if not re.search(r"[A-Za-z]", password): # At least one letter
return False
if not re.search(r"\d", password): # At least one digit
return False
return True
It’s also important to align this policy with the password change route, as the current setup (minimum of 5 characters) doesn’t apply here, meaning any weak password could be set.
/reset-password/<token>
if not is_valid_password(new_password):
flash('Password must be at least 8 characters long and include at least one letter and one number', category='error')
else:
user = token_entry.user
user.password = generate_password_hash(new_password)
/change_password
elif not is_valid_password(new_password):
flash('Password must be at least 8 characters long and include at least one letter and one number', category='error')
Now, if a password like '123' is entered, the system will display an error, ensuring compliance with our improved password policy.
Another improvement related to password management is requiring users to enter their current password when changing it from the profile page. While this measure can help mitigate CSRF attacks on the password change route, it is not the primary defense against such attacks since other routes may still be vulnerable.
Requiring the current password is a best practice because it ensures that the person attempting the change is the legitimate account holder. This prevents unauthorized users who might have gained temporary access to the account from changing the password.
Additionally, it helps mitigate insider threats, for example, if a device is left logged in, this requirement acts as an extra safeguard against others making unauthorized changes to the password without explicit permission.
# Check if current password is correct
if not check_password_hash(current_user.password, current_password):
flash('Incorrect password, try again.', category='error')
Password Reset Tokens
Another important aspect of password management is handling the expiration and deletion of reset tokens. Currently, our reset tokens are not set to expire, nor are they removed from the database after use.
To address this, we can ensure tokens are deleted from the database once they are used in the reset-password
route:
# Removing token from database
db.session.delete(token_entry)
Alternatively, we can implement an automatic expiration policy where tokens expire after 1 hour if unused.
To achieve this, we need to update the database model to include a timestamp field indicating when the token was created:
from datetime import datetime
class Token(db.Model):
...
created_at = db.Column(db.DateTime, default=datetime.utcnow)
We then need to modify the password reset logic to check whether 60 minutes have passed since the token's creation. If the token is older than 60 minutes, it will be considered expired and the reset process will be halted.
import datetime
...
expiration_time = token_entry.created_at + datetime.timedelta(minutes=1)
if datetime.datetime.utcnow() > expiration_time:
flash('The reset link has expired. Please request a new one.', 'error')
db.session.delete(token_entry) # Remove the expired token
db.session.commit()
return render_template('reset_password.html', token=token, user=current_user)
File Upload
Filename
Another security enhancement involves the file upload process. One of the best practices is to rename uploaded files to a randomized, unique name. This step significantly reinforces the security of the application by mitigating filename-based attacks, accidental file overwrites, and content-type confusion.
Renaming files to unpredictable, unique names prevents unauthorized access by making it difficult for attackers to guess filenames. This strategy also helps protect against path traversal attacks and reduces the risk of executing malicious file types. Additionally, it safeguards user privacy by obfuscating file names.
A secure implementation could involve naming files using a combination of user ID, timestamp, or a random hash, ensuring each filename is unique and secure.
import uuid
...
# Generate a random filename with UUID
randomized_filename = f"{uuid.uuid4().hex}.{extension}"
# Save the file
file.save(os.path.join(upload_folder, randomized_filename))
current_user.image = randomized_filename
Now we see that the filename of the newly uploaded image is random.
File Size
Another crucial step is to limit the file size. Large image files are unnecessary and can be used by attackers to overload the server's storage, potentially causing a denial of service or wasting resources.
To mitigate this risk, we will restrict the file size to 2MB for each uploaded file. This ensures that files remain within a reasonable size and helps prevent abuse by limiting the impact of oversized uploads.
# Limiting file size
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 # 2 MB in bytes
# Register error handler
@app.errorhandler(RequestEntityTooLarge)
def handle_file_size_error(e):
return jsonify({"error": "File is too large!"}), 413
When attempting to upload a file larger than the set limit, the server will respond with an error message, as shown below.
Others
Another important improvement is to remove specific responses during login and password reset, replacing them with more generic messages to avoid user enumeration attacks.
Currently, the system provides different responses based on whether the email is registered or not. This behavior allows attackers to determine if a specific user exists by analyzing the response.
For example, the attempts above indicate that the user "bido" exists based on the different messages returned.
To mitigate this, we can standardize the responses, ensuring that they remain generic regardless of whether the email is valid or not.
/login
if user:
if check_password_hash(user.password, password):
flash('Logged in succesfully!', category='success')
login_user(user, remember=True)
return redirect(url_for('views.home'))
flash('Email or password incorrect', category='error')
/forgot-password
# Checking if user exists
if user:
<REDACTED>
flash('A recovery email has been sent to your address!', 'success')
return redirect(url_for('auth.login')) # Redirect to the login page after submission
Expiring Sessions
Another important security improvement is expiring user sessions after a set period of time, such as 60 minutes.
Expiring sessions helps enhance application security by reducing the risk of unauthorized access. If sessions are allowed to remain open indefinitely, there’s an increased chance that an attacker could hijack an idle or abandoned session. By automatically expiring sessions after a fixed duration of inactivity, we limit the window of opportunity for attackers to reuse a session token.
This measure also adds an extra layer of protection against session fixation and helps ensure that only active users can maintain access, reducing the potential for unauthorized access over time.
@app.before_request
def before_request():
session.permanent = True
app.permanent_session_lifetime = timedelta(minutes=60)
session.modified = True
app.config['REMEMBER_COOKIE_DURATION'] = 0
Debug Mode
The final modification we’ll make is disabling the debug flag in the main.py
file. This is a critical security measure, as running an application in debug mode exposes sensitive information that can be exploited by attackers. Debug mode can reveal details such as stack traces, internal errors, and environment settings, which could provide valuable insights into vulnerabilities in your application.
For a production environment, it is essential to ensure that the application is not running in debug mode. This minimizes the risk of exposing internal application details and enhances the overall security of your system. Always remember to turn off debugging before deploying your app to production.
if __name__ == '__main__':
# app.run(debug=True)
app.run()
Two additional security measures we won’t be implementing in this project are brute force protection at the login page and using HTTPS to encrypt the web connection. While both are essential in real-world applications, especially for protecting user accounts and securing communication, we’ve decided not to apply them here, as this is a prototype project.
However, in a production environment, it is crucial to implement brute force protection mechanisms, such as rate limiting or CAPTCHA, to prevent automated attacks. Similarly, HTTPS is fundamental to ensure that data transmitted between the client and server is encrypted, safeguarding sensitive information from being intercepted by attackers. These are best practices that should always be considered when moving to a live application.
Conclusion
With these enhancements, our application now has a significantly stronger security posture! In the upcoming chapters, we’ll integrate a SAST (Static Application Security Testing) tool into our repository and evaluate how it performs security analysis on our website.
For these code changes, we’re on branch improvements and the commit reference is 23089d278000e480a5683fa22771cb00a5e0b4ea
.
Stay tuned and see you in the next chapter! =)
Subscribe to my newsletter
Read articles from b1d0ws directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by