In our previous courses, we took a deep dive into client-side validation when working on the user registration feature. However, it’s crucial to note that client-side validation can be bypassed. Attackers can deliberately circumvent client-side restrictions, emphasizing the importance of robust server-side validation. Let’s now shift our focus to the snippets part of our application and demonstrate secure server-side validation using TypeScript.
Server-side validation is your final gatekeeper to ensure that data is clean and meets the expected requirements. Even if malicious users bypass client-side checks, robust server-side validation will stop unsafe or malformed data from compromising your application. TypeScript’s strict typing, combined with libraries like Zod
, offers a structured way to define and enforce these rules. While TypeScript enforces type safety at compile time, ensuring that variables and functions receive the expected types before the code is run, it is erased at runtime. This means incoming user input is still just a raw JavaScript object, allowing attackers to send unexpected values, such as null
, objects instead of strings, or excessively large inputs. Therefore, using runtime validation libraries like Zod
is essential to dynamically enforce data constraints.
Below is a snippet of our “save snippet” endpoint. Currently, there is no server-side validation in place to verify the data contained in the request body:
Since the snippet is directly created from user input, attackers could insert malicious data (e.g., harmful scripts) into these fields.
Even with the correct token, the lack of server-side validation allows attackers to exploit the endpoint. They can bypass client-side validation and send raw requests with malicious payloads:
This request, despite being authenticated, can inject harmful scripts into the application. Without proper server-side validation, these payloads could be stored or rendered elsewhere, leading to cross-site scripting (XSS) or other injection-based exploits. This highlights the critical need for robust server-side validation to protect against such vulnerabilities.
Zod
is a TypeScript-first schema declaration and validation library. It allows developers to define schemas for data structures and validate data against these schemas. Zod
is particularly useful for ensuring that incoming data meets specific criteria, which is crucial for maintaining application security and integrity.
Zod
provides a simple and expressive way to define the shape and constraints of data. It supports a wide range of data types and validation rules, making it a powerful tool for both server-side and client-side validation. By using Zod
, you can ensure that data adheres to the expected format before it is processed or stored, reducing the risk of security vulnerabilities and data corruption.
In the code snippet below, we define a Zod
schema for validating snippet data:
-
z.object({...})
: This function creates a schema for an object, specifying the expected structure and types of its properties. -
title: z.string().min(3).max(100)
: This line defines thetitle
property as a string with a minimum length of 3 characters and a maximum length of 100 characters. This ensures that the title is neither too short nor excessively long, which helps prevent certain types of attacks and data issues. -
content: z.string().max(5000)
: Thecontent
property is defined as a string with a maximum length of 5000 characters. This constraint prevents overly large inputs that could lead to performance issues or buffer overflows. -
language: z.enum([...])
: Thelanguage
property is defined as an enumeration, allowing only specific values: "typescript", "javascript", "python", "java", and "cpp". This restricts the input to a predefined set of valid options, preventing unexpected or harmful values from being processed.
By setting these boundaries and constraints, the Zod
schema helps block dangerous or unexpected input, ensuring that only valid data is processed by the application. This approach enhances security by reducing the risk of injection attacks and other vulnerabilities.
Middleware functions in Express.js are functions that have access to the request (req
), response (res
), and the next middleware function in the application’s request-response cycle. They can execute code, modify the request and response objects, end the request-response cycle, and call the next middleware function. Middleware is a powerful way to handle tasks such as logging, authentication, and validation in a modular and reusable manner.
In the code snippet below, we create a middleware function to validate incoming data against the Zod
schema:
-
const validateSnippet = (req, res, next) => { ... }
: This defines a middleware function namedvalidateSnippet
. It takes three parameters:req
(the request object),res
(the response object), andnext
(a function to pass control to the next middleware). -
snippetSchema.safeParse(req.body)
: This line uses thesafeParse
method from Zod to validate the request body against thesnippetSchema
. ThesafeParse
method returns an object with asuccess
property indicating whether the validation passed, and anerror
property containing details if it failed. -
if (!parsed.success) { ... }
: This conditional checks if the validation was unsuccessful. Ifparsed.success
isfalse
, it means the input data did not meet the schema's requirements. -
return res.status(400).json({ error: "Invalid input", details: parsed.error.errors });
: If validation fails, the middleware sends a 400 Bad Request response with an error message and details about the validation errors. This prevents the request from proceeding further, stopping unsafe or malformed data from reaching the database. -
next();
: If the validation is successful, thenext()
function is called to pass control to the next middleware function or route handler in the stack. This allows the request to continue through the application’s request-response cycle.
To enhance the middleware function, we can add logging to capture validation errors, which aids in debugging and monitoring. This addition will provide insights into why certain requests fail validation, allowing developers to address issues more effectively.
Explanation of the Logging Addition
console.error("Validation Error:", parsed.error.errors);
: This line logs the validation errors to the console whenever validation fails. It provides detailed information about the nature of the validation error, including the specific field that failed and the reason for the failure.
Example Output
If a snippet is submitted with a title that is less than 3 characters, the console will output:
By implementing this middleware, you ensure that all incoming data is validated before it is processed by your application. This approach helps maintain data integrity and security by preventing invalid or malicious data from being stored or executed.
After defining the validation middleware, the next step is to incorporate it into the "save snippet" endpoint. This ensures that all incoming data is validated before any further processing occurs, thereby maintaining the integrity and security of your application.
Explanation
-
router.post('/', validateSnippet, async (req, res) => { ... })
: ThevalidateSnippet
middleware is added as the second argument in the route definition. This ensures that the request body is validated against thesnippetSchema
before the main logic of the endpoint is executed. -
Authorization Check: The endpoint first checks for the presence of an authorization header. If it's missing, a 401 Unauthorized response is returned. This step ensures that only authenticated users can proceed.
-
Token Verification: The token from the authorization header is verified using
jwt.verify
. If the token is invalid, a 401 Unauthorized response is returned. This step confirms the user's identity and permissions. -
Data Processing: If the request passes both validation and authentication, the data is extracted from the request body and used to create a new snippet. The
Snippet.create
method is called with the validated data, ensuring that only safe and expected data is stored in the database.
By integrating the validateSnippet
middleware into the endpoint, you create a robust layer of defense against invalid or malicious data. This approach not only protects your application from potential security threats but also ensures that the data stored in your system is consistent and reliable.
• Client-Side Validation: Runs in the user’s browser before data is sent to the server, providing quick feedback to users and reducing unnecessary network traffic if the input is obviously invalid. However, because it runs on the client, it can be disabled or tampered with by attackers using tools like cURL or custom scripts.
• Server-Side Validation: Occurs on the server once data has been received. This acts as the ultimate authority for data integrity, preventing malicious or malformed data from entering the backend systems or databases. Even if client-side checks are bypassed, server-side validation remains in place to protect your application.
Both forms of validation can coexist to deliver a better user experience and enforce strict security measures. While you should strive to provide immediate feedback through client-side checks, you must never skip or rely solely on them. Proper server-side validation is essential for protecting your application against sophisticated attacks.
By applying Zod-based validation in conjunction with TypeScript, you reinforce your application’s defenses at the server level. This complements any previous client-side checks you implemented (such as those used in registration flows) and helps maintain robust security for all new code dealing with user-provided data.
