This week, I was tasked with enhancing the security of our Create React App by employing obfuscation and minification techniques.
Obfuscation, as the name implies, obscures the source code, intentionally making it complex for humans to decipher while retaining its functionality. Minification, on the flip side, compresses code, trims the fat, and optimizes size for efficient delivery.
During my intial search i came across some outdated libraries like javascript-obfuscator
and uglify-js
(as if javascript code can get any uglier, am I right?). Then, I stumbled upon Terser, a modern library that supports ES6.
Reading through the documentation I was able to apply a simple enough script for my package.json
:
"obfuscate:" "terser build/static/js/main.*.js --compress --mangle --output main.min.js"
Which I then tinkered with using xargs for it to update the same file that was used instead of generating a new one:
"obfuscate": "find build/static/js -type f -name 'main.*.js' -print0 | xargs -0 -I {} terser {} --compress --mangle --output {}",
I also came across an article recommending setting the GENERATE_SOURCEMAP
to false. A source map is a file that maps the minified or transpiled code back to its original source code, aiding in debugging. While source maps may be valuable during development for debugging purposes, they can potentially expose sensitive information about your code structure and logic. So my final script looks like this:
"build": "GENERATE_SOURCEMAP=false react-scripts build && yarn obfuscate",
Now when I open the website, the files are obfuscated and minified:
(Apologies for the light mode, i don't usually use the browser i printed this on. This is not an obfuscation technique, as it is local to my browser)
So, job well done, right?
However, a colleague conducting security checks on the application discovered that it remained remarkably easy to discern all the routes by searching for the path. This is understandable since the route names can't be effectively obfuscated, as the pathnames must be preserved for users to be able to access the routes. While this is acceptable for public routes, every file in the application is somehow present in the /build/static/js/main.*.js
file, given that it's a JavaScript SPA.
The issue arose because our app contained routes meant to be 'secret' and hidden from regular users. Although these routes were protected by authentication, exposing them wasn't an ideal scenario
Chunkification
Now, here comes the really interesting part of this article. The key to obfuscating routes lies in leveraging the power of React's lazy loading and suspense mechanisms.
Lazy Loading: Lazy loading allows you to load specific parts of your application only when they are needed. This is particularly beneficial for large applications with numerous components. React's
lazy
function enables you to dynamically import components, ensuring that they are only fetched and rendered when required. This helps in reducing the initial loading time and improving the overall performance of your application.Suspense: Suspense is a React feature that allows components to wait for something before rendering. It enables you to manage the loading states of your components more efficiently. With the
Suspense
component, you can specify fallback content to display while waiting for the main content to load. This creates a smoother and more seamless user experience, especially when dealing with asynchronous operations such as lazy loading.
By strategically employing these features, we can dynamically split and load code associated with specific routes into separate chunks within your build/static
folder, and it will only be loaded when the route is accessed. This not only enhances the security of your application but also optimizes the loading of resources, providing a more efficient and seamless user experience.
When applying this technique and building the app, our Chunk
will appear like this:
Now, let's go to the implementation
Implementation in Routing
In your main routing configuration (app/index.js
), you can structure it as follows:
// app/index.js
<Router>
<Routes>
<Route path="/*" element={<IndexRoutes />} /> {/* Regular routes */}
<Route path="/admin/*" element={<Suspense fallback={<></>}><AdminRoutes /></Suspense>} /> {/* Routes you want to hide */}
</Routes>
</Router>
The AdminRoutes component, which contains the routes you wish to secure (routes/admin.jsx), could look something like this:
// routes/admin.jsx
<Routes>
<Route path="/secret-clients/:slug" element={<AdministrativePage />} />
<Route path="/super-secret-page" element={<SuperSecretPage />} />
{/* ... */}
</Routes>
This approach allows the existence of the /admin namespace to be evident in your main.*.js
file. However, the actual code related to this namespace is encapsulated in a separate chunk that won't load until the corresponding routes are accessed.
There is just one potential issue: While our admin chunk is inaccessible from regular routes, accessing host/admin/{anything}
would load a page. The page would be blank, but unfortunately for us, this also triggers the loading of the associated chunk, potentially exposing information about the routes.
To add an extra layer of protection against unauthorized access to our chunk, we can implement a redirect strategy within our routes/admin.jsx
file:
// routes/admin.jsx
<Routes>
<Route path="/actions/:slug" element={<AdministrativePage />} />
<Route path="/super-secret-page" element={<SuperSecretPage />} />
{/* ... */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
By including <Route path="*" element={<Navigate to="/" />} />
, we ensure that any attempt to access our chunk with an unknown path will result in an immediate redirect to the homepage. While this provides improved security, there's still a brief moment when the chunk is accessible before the redirect occurs, and you know hackers these days, with their black hoodies and big brains. If they can download cars, they could certainly download our chunk if they tried hard enough.
To further enhance security, we can integrate this strategy with user authentication. Returning to our app/index.js
, we can modify the routing configuration:
// app/index.js
import { isAdminLoggedIn } from './dir' /* function to verify authentication*/
{...}
<Router>
<Routes>
<Route path="/*" element={<IndexRoutes />} /> {/* Regular routes */}
<Route path="/auth/*" element={<IndexRoutes />} /> {/* Auth routes */}
{isAdminLoggedIn() && <Route path="/admin/*" element={<Suspense fallback={<></>}><AdminRoutes /></Suspense>} />} {/* Routes you want to hide */}
</Routes>
</Router>
By implementing authentication in combination to the chunk strategy, even if an unauthenticated user were to try and access the /admin
namespace, it would not trigger the chunk loading, therefore, protecting our app further.