5 best practices for React with TypeScript security
Marcelo Oliveira
2022年12月8日
0 分で読めますAs a library focused on building user interfaces rather than a full-fledged framework, React enables developers to choose their preferred libraries for various aspects of an application, such as routing, history, and authentication. Comparatively, Microsoft created TypeScript as an extension of JavaScript to introduce optional static typing to an otherwise loosely typed language.
Using TypeScript with React provides several advantages in application-building, including the option of simpler React components and better JavaScript XML (JSX) support for static type validation. As we can use JavaScript components in a TypeScript project, development teams with JavaScript experience can leverage this knowledge to benefit from strong-typing programming.
Many boilerplates are available for starter React projects, including Create React App, Create Next App, Vite, React Boilerplate, and React Starter Kit.
Create React App is a standalone tool that can run with either npm or Yarn. Once installed, we can generate and run a new project with just a few commands.
First, open your terminal and run the following command to install the Create React App tool:
1npm install -g create-react-app
Then, create a project using the TypeScript template with node package execute (npx)
:
1npx create-react-app [webapp-name] --template typescript
Alternatively, you can create the project using the Yarn package manager:
1yarn create react-app [webapp-name] --template typescript
potential security risks, and how to mitigate them.
Enable strict mode
Strict mode automatically turns on the TypeScript compiler parameters related to data type rules. When TypeScript strict mode is enabled, it validates the code using the strict type rules, forcing developers to write code respecting the limitations of the data types assigned to variables, constants, parameters, and function return values. Strict mode is important because it enables developers to catch and fix type mismatch bugs early in the development phase.
Suppose an application has the following function:
1export async function deleteComment(slug, commentId): Promise<void> {
2 await axios.delete(`articles/${slug}/comments/${commentId}`);
3}
We invoke the deleteComment
function by passing the slug
argument as a string and commentId
as a number:
1deleteComment('whats-new-in-react', 1257);
However, this doesn’t prevent us from invoking the function with two strings:
1deleteComment('foo', 'bar');
The code above may not be allowed by business rules, but since we haven’t provided types, both parameters are assumed to be of the default any
type. Consequently, TypeScript can’t help us identify the problem unless we enable strict mode.
Newly created React applications come with the strict
value set to true
. However, this value may differ for existing projects.
To ensure that our TypeScript project is running in strict mode, open the tsconfig.json
file and check that the value of the strict configuration is true
:
1{
2 ...
3 "strict": true,
4 ...
5}
Return to the deleteComment
function. One small change immediately produces TypeScript compilation errors:
Add the string and number types to the deleteComment
parameters:
1export async function deleteComment(slug: string, commentId: number): Promise<void> {
2 await axios.delete(`articles/${slug}/comments/${commentId}`);
3}
This produces compilation errors, preventing developers from creating client code that inadvertently calls the deleteComment
function with invalid types, like in the example below:
1deleteComment(null, true);
The strict option automatically enables other recommended compiler options related to stricter type-checking.
Don’t use return type any
in callbacks whose value will be ignored
If you declare a callback’s return type as any
, then inadvertently use its return value when the function doesn’t return a value, you can make a mistake that goes undetected.
Consider a function named onListFieldKeyUp
that takes a callback named onEnter
as a parameter:
1export function onListFieldKeyUp(onEnter: () => any): (ev: React.KeyboardEvent) => void {
2 return (ev) => {
3 if (ev.key === 'Enter') {
4 ev.preventDefault();
5 var enterResult = onEnter();
6 //do something with enterResult
7 }
8 }
9 };
10}
Note how the enterResult
variable above stores the result of the onEnter
callback function for further use. Although the callback doesn’t return a value, we declared the enterResult
variable with type any
, so TypeScript is unable to alert you to a problem.
What’s the solution to this?
First, if you know the onEnter
function doesn’t return a value, replace the any
type in the callback parameter with void
. Now, TypeScript displays the error “An expression of type 'void' cannot be tested for truthiness.
Finally, remove the code that stores the return value of onEnter
in the enterResult
variable:
1export function onListFieldKeyUp(onEnter: () => void): (ev: React.KeyboardEvent) => void {
2 return (ev) => {
3 if (ev.key === 'Enter') {
4 ev.preventDefault();
5 onEnter();
6 }
7 };
8}
Server-side rendering attacks in React
A web application can render HTML on the client or the server. Modern JavaScript frameworks and libraries, like React, adopt server-side rendering. This approach provides performance benefits, including accelerated page loading — allowing the back end to quickly pre-render the entire page and pass the static HTML, CSS, and JavaScript content to the front end. As a result, users can navigate and see the web page instantly. This approach also helps with search engine optimization (SEO) since fast-loading pages reach higher scores in search algorithms.
Cross-site scripting (XSS) is an attack modality where attackers inject malicious client-side scripts into a web page. React was designed to be safe from XSS. However, improper programming and server-side rendering in React can lead to a XSS vulnerability that malicious users will exploit.
For example, never concatenate unsanitized data with the output of the renderToStaticMarkup
function before sending the string to the client:
1app.get("/", function (req, res) {
2 return res.send(
3 ReactDOMServer.renderToStaticMarkup(
4 React.createElement("h1", null, "Hello World!")
5 ) + someUnsanitizedData
6 );
7});
The code above is unsafe because a hacker may have compromised the someUnsanitizedData
variable to include malicious JavaScript code, like below:
1someUnsanitizedData = "</scrïpt><scrïpt>alert('You are compromised!')</scrïpt>
To prevent XSS attacks, use an HTML sanitizer such as DomPurify.
Use opaque types
An opaque data type enforces information-hiding. Its data structure is not defined in the interface, which hides and encapsulates the implementation of a concrete data type. While external modules can use the opaque type without accessing its internals, internal functions with access to the missing information can manipulate the type. Opaque types enable you to change and evolve internal details without changing the code that uses them. Therefore, using them remains a development best practice.
While TypeScript does not provide opaque types out of the box, we can implement them easily. Let’s try solving a real-world use case.
Imagine an e-commerce application using a function in TypeScript to add a product to the customer cart:
1function addToCart(customerCode: string, productCode: string) {
2 console.log(`Product ${productCode} has been added to the cart of the customer ${customerCode}`);
3}
These typed parameters ensure the programmer won’t provide numbers or other types in place of strings for the customerCode
and productCode
parameters:
1addToCart('ABC-001984', 'SPC-004487');
Though there isn’t anything wrong with the code, the type fails to precisely express the values. You can’t tell which parameter is the customer or product code just by looking at the code line, and TypeScript won’t give a warning if both values are swapped.
Now look at how the addToCart
was refactored in the example below to use opaque types:
1export type CustomerCode = string & { _: 'CustomerCode' };
2export type ProductCode = string & { _: 'ProductCode' };
3
4const makeCustomerCode =
5 (customerCode: string): CustomerCode => {
6 if (/^\w{3}-\d{6}$/.test(customerCode)) { //regex validation
7 return customerCode as CustomerCode;
8 } else {
9 throw new Error('Not a customer code!');
10 }
11 };
12
13const makeProductCode =
14 (productCode: string): ProductCode => {
15 if (/^\w{3}-\d{6}$/.test(productCode)) { //regex validation
16 return productCode as ProductCode;
17 } else {
18 throw new Error('Not a product code!');
19 }
20 };
21
22function addToCart(customerCode: CustomerCode, productCode: ProductCode) {
23 console.log(`Product ${productCode} has been added to the cart of the customer ${customerCode}`);
24}
25
26let customerCode: CustomerCode = makeCustomerCode('ABC-001984');
27let productCode: ProductCode = makeProductCode('SPC-004487');
28
29addToCart(customerCode, productCode);
Thanks to our new opaque types and their regex validation, the code produces a compiler error if you provide incorrect codes:
1let customerCode: CustomerCode = makeCustomerCode('000-001984'); //Error: Not a customer code!
2let productCode: ProductCode = makeProductCode('9871'); //Error: Not a product code!
When to use dangerouslySetInnerHTML
and observe proper sanitization practices
React uses a Virtual DOM as a lightweight strategy to update the page’s HTML efficiently, preventing users from having to deal with native browser APIs directly to manipulate HTML elements.
However, at times you may have to override this mechanism and set raw HTML code in your React applications. To directly manipulate this HTML code, React uses a special component property called dangerouslySetInnerHTML
:
1return (
2<p dangerouslySetInnerHTML={{__html: data}}></p>);
As the name suggests, the dangerouslySetInnerHTML
property makes an application vulnerable if not used properly. Hackers can exploit applications relying on dangerouslySetInnerHTML
to perform Cross-Site Scripting (XSS) attacks and inject malicious scripts disguised as trusted user input into your website.
To prevent this risk, always sanitize the HTML content before inserting it to eliminate “impurities” or malicious code. You can use a library like DOMPurify
and apply the sanitize
function:
1import DOMPurify from 'dompurify';
2
3return (
4<p dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(data)}}></p>);
It’s crucial to note that, like any other library, DOMPurify
is prone to the occasional security vulnerability. Fortunately, the Snyk vulnerability database quickly incorporated this vulnerability data and provided a solution. So, regardless of a library’s stable history, we should still practice our due diligence by using a tool like Snyk to periodically scan for any known vulnerabilities.
And that’s just one of the security concerns developers should consider. In this video, Liran Tal discusses how React developers can make mistakes, leading to other vulnerabilities that hackers can exploit.
Conclusion
In this article, we discussed some best practices for developing React applications with TypeScript, outlined potential security risks, and prescribed solutions.
Strict mode allows you to enforce type constraints and catch type mismatch mistakes early in development. Using
void
in callbacks prevents developers from using a return value from callbacks that don’t return a value.Command injection attacks are a serious security threat. Avoid building dynamic code with suspicious user input and use the
execFile
function instead ofexec
.HTML injections are another serious security threat. When using
dangerouslySetInnerHTML
, always sanitize the inserted markup to prevent attackers from successfully tampering with user input and injecting malicious code.Finally, opaque types provide meaningful domains and help you more easily validate types and avoid duplicates.
It’s easy to use TypeScript and React to build fast, secure applications, especially using frameworks like Create React App and previous knowledge of JavaScript.