Form validation is an essential aspect of web development. It plays a crucial role in maintaining an application’s security and integrity. Forms are one of the exposed parts of an application because they accept external inputs from users. Through these external form inputs, malicious users or attackers can enter malicious scripts or files that can destroy the integrity of your application and access your users' private data.
Although text-based inputs are the primary targets for attackers attempting to inject malicious scripts into an application, file inputs—not directly vulnerable to script injections—are the most disguised point of entry and are less common. Attackers can use file uploads to embed malicious scripts in ways that are not immediately obvious.
Form validation is a way of preventing these malicious scripts in your application. Zod is a schema validation library for JavaScript and TypeScript that helps enforce data structures and validate inputs. Unlike typical validation libraries, Zod integrates seamlessly with TypeScript, providing strong type safety at runtime. In this article, you’ll learn how to validate your file inputs with Zod, a schema validation library for TypeScript/JavaScript.
Zod’s Overview
Zod is a TypeScript-first schema declaration and validation library that mimics TypeScript’s strong static typing. It allows you to enforce type safety at runtime, making it an excellent choice for TypeScript developers who want strong typing and reliable validation without compromising either.
Zod provides a powerful and flexible way to define schemas for your data structures. Its schema’s syntax is straightforward and you only need to use the ‘z’ object imported from ‘zod’. The ‘z’ object includes methods for various data types such as:
import { z } from "zod";
// Basic primitive schemas
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
// Object Schema
const userSchema = z.object({
name: z.string(),
age: z.number().int(), //built-in method: Only allows integers
email: z.string().email(), //built-in method: Validates email format
});
// Array schema
const stringArraySchema = z.array(z.string());
// Optional and Nullable types
const userSchema = z.object({
name: z.string(),
age: z.number().int().optional(), // age is optional
email: z.string().email().nullable(), // email can be null
});
Project Setup
For this article, you need two dependencies: React and Zod. Create a Typescript variant of a React application with Vite and install Zod.
// create react app with vite
npm create vite@latest file-input-validation -- --template react-ts
cd file-input-validation
npm install
// install zod
npm install zod
After running the above command in your terminal, open the file-input-validation
directory in your code editor, and run npm run dev
to kickstart the application in localhost.
Now that your application is running successfully, delete the App.css
file, clear out the App.tsx
and index.css
files, and replace the former with the code block below.
As for the styles, see it here.
// App.tsx
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DOCUMENT_SCHEMA, IMAGE_SCHEMA } from "./utils/schema";
import { useState, useEffect } from "react";
interface ErrorType {
img_upload?: string;
doc_upload?: string;
}
function App() {
const [docFile, setDocFile] = useState<File | undefined>();
const [imgFile, setImgFile] = useState<File | undefined>();
const [imgUrl, setImgUrl] = useState("");
const [error, setError] = useState<ErrorType>({});
useEffect(() => {
if (imgFile) {
const url = URL.createObjectURL(imgFile);
setImgUrl(url);
return () => URL.revokeObjectURL(url);
}
}, [imgFile]);
const handleDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
};
const handleImgChange = (e: React.ChangeEvent<HTMLInputElement>) => {
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<div className="app-container">
<h1>File Input Validation with Zod</h1>
<div className="form-container">
<form className="form" onSubmit={handleSubmit}>
<div className="formfield">
<label htmlFor="doc-input">
<p>Document Input</p>
<div className="doc-label">
{docFile?.name ? (
<p>{docFile?.name}</p>
) : (
<p>
<span>Browse</span> to upload document here{" "}
</p>
)}
<p className="size">(5MB Max)</p>
</div>
</label>
<input
id="doc-input"
name="doc_upload"
type="file"
onChange={handleDocChange}
accept="application/*"
/>
{error.doc_upload && <p className="error">{error.doc_upload}</p>}
</div>
<div className="formfield">
<label htmlFor="img-input">
<p>Image Input</p>
<div className="image-label">
{imgUrl ? (
<img src={imgUrl} alt="img-input" />
) : (
<div>
<p>
<span>Browse</span> to upload image here{" "}
</p>
<p className="size">(5MB Max)</p>
</div>
)}
</div>
</label>
<input
id="img-input"
name="img_upload"
type="file"
accept="image/*"
onChange={handleImgChange}
/>
{error.img_upload && <p className="error">{error.img_upload}</p>}
</div>
<button
type="submit"
disabled={
!!error.doc_upload || !!error.img_upload || !docFile || !imgFile
}
>
Submit
</button>
</form>
</div>
</div>
);
}
export default App;
For now, leave the functions empty. You’ll fill them up later as you proceed.
Validating File Inputs
The HTML file element allows any file type including video, audio, image, and documents. When defining your file inputs, you can dictate the file types it should receive using the accept
attribute—perhaps only image files or a combination of video and audio files. The accept
attribute takes a string value of any valid unique file type identifier and prevents the file input from accepting any file with a type not corresponding to the specified type identifier. Using the accept
attribute, you can restrict file types to exclude dangerous formats like executable files (.exe), scripts (.js), and macro-enabled documents (.docm).
File Type Validation
With Zod, you can also define the file types your input should accept as an extra layer of security. In your App.tsx
file are two file inputs—one for images and the other for documents, and you’ll write a Zod schema to validate each with the instanceof
and refine
methods.
import { z } from "zod";
// Document Schema
export const DOCUMENT_SCHEMA = z
.instanceof(File)
.refine(
(file) =>
[
"application/pdf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
].includes(file.type),
{ message: "Invalid document file type" }
);
// Image Schema
export const IMAGE_SCHEMA = z
.instanceof(File)
.refine(
(file) =>
[
"image/png",
"image/jpeg",
"image/jpg",
"image/svg+xml",
"image/gif",
].includes(file.type),
{ message: "Invalid image file type" }
);
The instanceof
method checks if a value is an instance of a specific class. In this case, both schemas check if the value is of the TypeScript File class. The refine
method allows you to define custom validation logic. It takes 2 arguments—a callback function expected to return a false value for a failed validation check and an object for passing customisable options for error-handling behaviours.
Zod takes in the File value and checks the type with the types listed in the array. If the file type is in the array, the file passes the validation. If not, Zod sends the error message.
File Size Validation
Besides the file type validation, you can augment the schema to validate the file size using the refine method again.
import { z } from "zod";
const fileSizeLimit = 5 * 1024 * 1024; // 5MB
// Document Schema
export const DOCUMENT_SCHEMA = z
.instanceof(File)
.refine(
(file) =>
[
"application/pdf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
].includes(file.type),
{ message: "Invalid document file type" }
)
.refine((file) => file.size <= fileSizeLimit, {
message: "File size should not exceed 5MB",
});
// Image Schema
export const IMAGE_SCHEMA = z
.instanceof(File)
.refine(
(file) =>
[
"image/png",
"image/jpeg",
"image/jpg",
"image/svg+xml",
"image/gif",
].includes(file.type),
{ message: "Invalid image file type" }
)
.refine((file) => file.size <= fileSizeLimit, {
message: "File size should not exceed 5MB",
});
Just like the file type is checked with the refine
method, the same method is used to check the file size by comparing it with the fileSizeLimit
defined in the code block above. If the file size is over the limit, Zod returns the defined error message.
In this way, you have coupled two validation checks in one schema to validate the file type and size.
Frontend Validation with Zod Schemas
The schemas are useless unless integrated with the form that provides the values to be validated. In the App.tsx
file, there are undefined functions, and you will define them in this section.
First and foremost, import your schemas into the App.tsx
file, then define the validation functions.
import { DOCUMENT_SCHEMA, IMAGE_SCHEMA } from "./utils/schema";
const validateFile = (file: File, schema: any, field: keyof ErrorType) => {
const result = schema.safeParse(file);
if (!result.success) {
setError((prevError) => ({
...prevError,
[field]: result.error.errors[0].message,
}));
return false;
} else {
setError((prevError) => ({
...prevError,
[field]: undefined,
}));
return true;
}
};
const handleDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const isValid = validateFile(file, DOCUMENT_SCHEMA, "doc_upload");
if (isValid) setDocFile(file);
}
};
const handleImgChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const isValid = validateFile(file, IMAGE_SCHEMA, "img_upload");
if (isValid) setImgFile(file);
}
};
The validateFile
function takes 3 arguments—the file, the schema, and the field. The schema safeParse’s the file and the result is stored in the result
variable. If the parse is unsuccessful, the function updates the field of the failed input in the error
state with the error message Zod returns and the function returns false. If successful, the field is set to undefined in the error state and the function returns true. By using the Zod safeParse
method, you allow your application to fail gracefully, preventing Zod from throwing errors, which could be a bad user experience.
handleDocChange
and handleImgChange
are the functions you pass to the doc_upload and img_upload inputs respectively. The functions monitor the files uploaded to their respective input files and run the validateFile
function on them, storing the response in the isValid
variable. If the variable is true, the functions update their respective states with the files.
Finally, the error state helps to store the error information about each input field. This information is displayed in the <p>
tag below each input field whenever any error state object property contains an error which corresponds to it.
Handling Multiple Files
By adding the multiple
attribute to your input element, you can select and upload multiple files at once. With Zod, you can write a validation schema for a FileList which is as easy as doing the same for a File.
const fileSizeLimit = 5 * 1024 * 1024; // 5MB
export const fileUploadSchema = z.object({
files: z
.instanceof(FileList)
.refine((list) => list.length > 0, "No files selected")
.refine((list) => list.length <= 5, "Maximum 5 files allowed")
.transform((list) => Array.from(list))
.refine(
(files) => {
const allowedTypes: { [key: string]: boolean } = {
"image/jpeg": true,
"image/png": true,
"application/pdf": true,
"application/msword": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
true,
};
return files.every((file) => allowedTypes[file.type]);
},
{ message: "Invalid file type. Allowed types: JPG, PNG, PDF, DOC, DOCX" }
)
.refine(
(files) => {
return files.every((file) => file.size <= fileSizeLimit);
},
{
message: "File size should not exceed 5MB",
}
),
});
There are two new Zod methods in this schema—object
and transform
. The object
method allows you to define a validation schema for an object and each property of the object. The transform
method allows you to convert a value from one form to the other. In this case, the FileList is converted to an array for easier manipulation.
With this schema defined, you can integrate it with your application’s form input elements as in the previous section.
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles) return;
const result = fileUploadSchema.safeParse({ files: selectedFiles });
if (result.success) {
setFiles(result.data.files);
setError(null);
} else {
setError(result.error.errors[0].message);
setFiles([]);
}
};
Preventing Security Risks and Handling Edge Cases
One of the main purposes of file validation is to secure your application against potential security risks that could include uploading some malicious scripts hidden in files. Even with a limitation on the types of accepted files, it's highly important to take additional measures to protect against them:
- Validate File Type and MIME Type: Make sure the uploaded files match their MIME type - such as
application/pdf
- and extension - such as.pdf
. Some file types, such as.svg
files, may contain scripts and must therefore be carefully validated or sanitized if they are to be permitted. - Clean and Limit Content Size: Where file types can be utilized to contain scripts, such as
.svg
or.docx
, for example, you may consider using server-side sanitization libraries to exorcise any potentially harmful content. - Rejected File Types for Suspicious Ones: If the file type is ambiguous, or if its MIME type doesn't correspond with the extension of the file, reject it and an graceful error message will be displayed to the user. This is a precautionary measure, which will prevent certain kinds of attack vectors, such as disguised executables.
Apart from security, file uploads do have many edge cases that need to be handled well in advance. The following are a few examples:
- Empty File Uploads: Users sometimes don't even notice when they select an empty file meaning one with 0 bytes of size. Zod can validate against this by having a minimum size requirement. A File which is Empty should be rejected because there may be an error in actually processing or displaying its content.
- Unexpected File Encodings: Some files use unusual encoding; this may cause errors in other applications. You can log or reject the files containing unexpected encoding.
- Intermittent Upload Errors: In real-world applications, users might face network interruptions or other issues during file uploading. You can include retry mechanisms or error prompts so that users can complete their uploads.
In summary, make your validation system robust and user-friendly. Also, bear in mind security risks and common edge cases to ensure the users' interactions with your application's file-handling features go smoothly, error-free, and safely.
Conclusion
Adding file upload validation by Zod creates an extra layer of security and further improves the user experience for your application. Zod validation schema makes it easy to implement comprehensive checks, and provide clear feedback to users. This feedback allows your users to understand and resolve the issues in their values quickly.
As you iterate on your validation logic, you're actively building a safer application that instils more trust and satisfaction in your user. Zod gives flexibility in handling file validation to set such standards out of the box with a solid base for any project needing file-handling functionality.
For a more comprehensive section about Zod, see the documentation
See the full project of this article here.
Top comments (0)