Blog
You Are Leaking Your Next.js Application Features
August 28, 2023
1114 Views
Conditionals. We love to, and have to use them as developers. But what does it take to break them? Well, possibly not much in a Next.js Client-Side hydrated application, it turns out!
Recently, I set out to implement a feature flag for a Next.js hobby project to keep a feature hidden from general users until it was internally tested. A simplistic sounding undertaking for sure, until I discovered I could easily bypass the feature flags on the client even with Server-Side Rendering in Next.js.
Before we dive into the flaw and how to reproduce it, lets take a look at some implementation details. Feature flags were implemented with the following tools:
- Upstash/Redis for the database - Reason? So I could learn something new
- Zod for parsing and transforming flag values - Reason? For convenience!
The Feature Flag
Here is what the implementation of the feature flag service looked like. Notice the use of Zod to parse 0
or off
and 1
or on
to true
or false
respectively.
We also store the feature flags as a hash in Redis, with the key featureFlags:{environment}
. This allows us to have different feature flags for different environments, and also allows us fetch all feature flags for a given environment.
lib/feature-flags.ts
TS
import { Redis } from "@upstash/redis";
import { z } from "zod";
// Setup Redis HTTP client
const redis = new Redis({
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
url: process.env.UPSTASH_REDIS_REST_URL!,
});
// Convert 0, 1 to false and true
const zeroOneBoolean = z
.union([z.literal(0), z.literal(1)])
.transform((val) => Boolean(val));
// convert on, off to true and false
const onOffBoolean = z
.union([z.literal("on"), z.literal("off")])
.transform((val) => val === "on");
const superBoolean = z.union([z.boolean(), zeroOneBoolean, onOffBoolean]);
// Define feature flag schema
export const featureFlagsSchema = z.object({
enableSecretFeature: superBoolean.optional().default(false),
});
// Helper type for feature flags
export type FeatureFlags = z.infer<typeof featureFlagsSchema>;
/** Fetches feature flags for given environment from Upstash */
export async function getFeatureFlags(environment = process.env.NODE_ENV) {
const key = `featureFlags:${environment}`;
const flags = (await redis.hgetall(key)) || {};
return featureFlagsSchema.parse(flags);
}
import { Redis } from "@upstash/redis";
import { z } from "zod";
// Setup Redis HTTP client
const redis = new Redis({
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
url: process.env.UPSTASH_REDIS_REST_URL!,
});
// Convert 0, 1 to false and true
const zeroOneBoolean = z
.union([z.literal(0), z.literal(1)])
.transform((val) => Boolean(val));
// convert on, off to true and false
const onOffBoolean = z
.union([z.literal("on"), z.literal("off")])
.transform((val) => val === "on");
const superBoolean = z.union([z.boolean(), zeroOneBoolean, onOffBoolean]);
// Define feature flag schema
export const featureFlagsSchema = z.object({
enableSecretFeature: superBoolean.optional().default(false),
});
// Helper type for feature flags
export type FeatureFlags = z.infer<typeof featureFlagsSchema>;
/** Fetches feature flags for given environment from Upstash */
export async function getFeatureFlags(environment = process.env.NODE_ENV) {
const key = `featureFlags:${environment}`;
const flags = (await redis.hgetall(key)) || {};
return featureFlagsSchema.parse(flags);
}
The Page
After this, the next step was using the feature flag in my Next.js application, running on Pages Router. This page uses getServerSideProps
to opt into Next.js' Server Side Rendering. In it, we load the feature flags from Upstash, pass them as props to the page, and then use them to conditionally render the secret feature.
pages/index.ts
TS
import { FeatureFlags, getFeatureFlags } from "@/lib/feature-flags";
import { GetServerSidePropsResult } from "next";
export default function Home({ featureFlags }: HomeProps) {
return (
<div>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</div>
);
}
type HomeProps = {
featureFlags: FeatureFlags;
};
export function getServerSideProps(): Promise<
GetServerSidePropsResult<HomeProps>
> {
const featureFlags = await getFeatureFlags();
return {
props: {
featureFlags: featureFlags,
},
};
}
import { FeatureFlags, getFeatureFlags } from "@/lib/feature-flags";
import { GetServerSidePropsResult } from "next";
export default function Home({ featureFlags }: HomeProps) {
return (
<div>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</div>
);
}
type HomeProps = {
featureFlags: FeatureFlags;
};
export function getServerSideProps(): Promise<
GetServerSidePropsResult<HomeProps>
> {
const featureFlags = await getFeatureFlags();
return {
props: {
featureFlags: featureFlags,
},
};
}
Now, we can go into Upstash and then set the enableSecretFeature
feature flag to 0
with hset featureFlags:{environment} enableSecretFeature 0
. After this we expect to see "Nothing to see here :)"
rendered in the browser when we load our app, and indeed we do!
Setting feature flag in Upstash Page when the feature flag is off
The Problem
Yay! Our feature flag correctly shows or hides our secret feature, rendered on the server. The problem, however, is that with a little bit of JavaScript on the client's browser, I can still access the secret feature! How, you might ask?
- First, inspect the web page and go to the browser console
- In the console window, and enter
window.__NEXT_DATA__.props.pageProps
to reveal the page's props as returned by ourgetServerSideProps
funciton. - Now modify the value for our feature flag with
window.__NEXT_DATA__.props.pageProps.enableSecretFeature = true
- Finally, trigger a client side re-render with the router with
window.next.router.push(window.next.router.pathname, undefined, {shallow:true})
Now you should be able to see the secret value 🤯.
Bypassing the feature flag from the browser
How Is this possible?
For some readers, this comes as no suprise at all. For me however, my mental model of Server-Side Rendering, which is what you opt into with getServerSideProps
, was incomplete. Using getServerSideProps
only means that pages are first 'pre-rendered' on the server (producing all of their HTML) - and then hydrated on the client.
The top-level hydration that occurs on the client makes the page 'come alive', making our page reactive to the props it receives. This makes it possible to bypass the feature flag on the client.
Note: the Next.js docs do warn against sending over sensitve information in the page props since they would be accessible on the client.
In this situation however, feature flags are not so secret, and are required at the rendering stage. So what solutions remain then?
Potential (Almost) Solutions
First, assume for a while that we cannot fundamentally get past this issue, what can we do to help the situation?
Backend Feature Flags
If the "secret feature" involves making a request to your backend, the backend should be able to detect that the feature should not be accessible, and abort the request! This way, even if our secret feature is accessed, it is only a shell, and we do not risk too much.
Practically, this could mean using the feature flags on the backend as well, and could be done at the middleware level to block routes from being accessed en masse.
middleware.ts
TS
import { NextResponse } from "next/server";
import { getFeatureFlags } from "./lib/feature-flags";
export async function middleware() {
const featureFlags = await getFeatureFlags();
if (!featureFlags.enableSecretFeature) {
return new NextResponse("unauthorized access", { status: 403 });
}
}
export const config = {
matcher: ["/api/secret-feature/:path*"],
};
import { NextResponse } from "next/server";
import { getFeatureFlags } from "./lib/feature-flags";
export async function middleware() {
const featureFlags = await getFeatureFlags();
if (!featureFlags.enableSecretFeature) {
return new NextResponse("unauthorized access", { status: 403 });
}
}
export const config = {
matcher: ["/api/secret-feature/:path*"],
};
With this change, all accesses to /api/secret-feature/:path*
are inaccessible while the enableSecretFeature
flag is off.
Conservative Server-Side Props
Another way to mitigate the problem is to be conservative with how much data we send over to the client. For example, if the enableSecretFeature
flag is used to conditionally render a featureMessage
message prop, we should only return featureMessage
to the client if the feature flag is on.
This will ensure that bypassing the feature flag results in an interface that a feature that is incomplete (and possibly unusable) to the user.
TS
export const getServerSideProps = async (): Promise<
GetServerSidePropsResult<HomeProps>
> => {
const featureFlags = await getFeatureFlags();
const defaultProps = {
featureFlags,
};
if (featureFlags.enableSecretFeature) {
return {
props: {
...defaultProps,
featureMessage: "This is the feature message",
},
};
}
return {
props: defaultProps,
};
};
export const getServerSideProps = async (): Promise<
GetServerSidePropsResult<HomeProps>
> => {
const featureFlags = await getFeatureFlags();
const defaultProps = {
featureFlags,
};
if (featureFlags.enableSecretFeature) {
return {
props: {
...defaultProps,
featureMessage: "This is the feature message",
},
};
}
return {
props: defaultProps,
};
};
Even with this mitigation, a really dedicated user could likely track down all missing properties needed in order to view the feature, or submit requests to your backend through the feature. However, this should be okay if your backend is able to detect illegal requests to it as discussed in the first mitigation - using feature flags on the backend to detect bad requests!
So, how might we solve this problem for real?
A Real Solution: Server Components
Heard the buzz about Server Components a.k.a React Server Components (RSC) yet? Well, situations like this are a big reason to consider them. Server Components execute and generate ready-to-serve HTML on the server, which is then delivered to the client. In this process, all conditionals are executed, and code paths that are not taken are left behind on the server. There is no hydration (of the component) on the client.
Using Server Components, we can manage to not even send over ANY feature flags in Client Components. Furthermore, very few modifications are required to make this happen!
Specifically, we move the page into the app
directory to make use of Next.js 13's App Router.
app/page.tsx
TS
import { getFeatureFlags } from "lib/feature-flags.ts";
export async function Page() {
const featureFlags = await getFeatureFlags();
return (
<div>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</div>
);
}
import { getFeatureFlags } from "lib/feature-flags.ts";
export async function Page() {
const featureFlags = await getFeatureFlags();
return (
<div>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</div>
);
}
That's it! Our feature flags now work and cannot be bypassed by the client. Our app is now more secure, and is also relieved of some JavaScript payload and rendering work of evaluating conditions.
The following migration guide from Next.js discusses how to migrate from the Pages Router to the App Router to be able to use Server Components to conditionally render content on the server.
Advanced Use Case - Controllable Flags
I have encountered scenarios where feature flags, had to be controllable by the client. For example, developers or internal testers needed may want to toggle feature flags when testing the application.
In such cases, the common approach could be to use client-side state management. Here, we would first load feature flags into an application-wide client-side Context and then update the value of the flag within the Context. However, this solution, again, would mean sending over JavaScript to the client which could be controlled by our highly determined user.
Alternatively, we can stick with the Server Component model, using either cookies or session for state, and updating the flags via requests from the client. These requests may be made through fetch
or through Server Actions if you are using Next.js.
Here is a quick example using Server Actions:
app/page.tsx
TS
import {
getFeatureFlagsWithSession,
saveFeatureFlagsToSession,
} from "@/lib/session";
import { revalidatePath } from "next/cache";
export default async function Page() {
const featureFlags = await getFeatureFlagsWithSession();
// Server action to handle form submission and revalidate the page
async function submit(formData: FormData) {
"use server";
try {
await saveFeatureFlagsToSession(formData);
} finally {
revalidatePath("/");
}
}
return (
<main>
<p>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</p>
<form action={submit} method="POST">
{/* Render the flags */}
{Object.entries(featureFlags).map(([name, value]) => {
return (
<label key={name}>
<input type="checkbox" name={name} defaultChecked={value} />
<span>{name}</span>
</label>
);
})}
<button type="submit">Save</button>
</form>
</main>
);
}
import {
getFeatureFlagsWithSession,
saveFeatureFlagsToSession,
} from "@/lib/session";
import { revalidatePath } from "next/cache";
export default async function Page() {
const featureFlags = await getFeatureFlagsWithSession();
// Server action to handle form submission and revalidate the page
async function submit(formData: FormData) {
"use server";
try {
await saveFeatureFlagsToSession(formData);
} finally {
revalidatePath("/");
}
}
return (
<main>
<p>
{featureFlags.enableSecretFeature
? "My Secret Feature!"
: "Nothing to see here :)"}
</p>
<form action={submit} method="POST">
{/* Render the flags */}
{Object.entries(featureFlags).map(([name, value]) => {
return (
<label key={name}>
<input type="checkbox" name={name} defaultChecked={value} />
<span>{name}</span>
</label>
);
})}
<button type="submit">Save</button>
</form>
</main>
);
}
In the code snippet above, getFeatureFlagsWithSession
fetches the feature flags from the redis, and merges them with the current session's feature flags, while the saveFeatureFlagsToSession
helper updates the feature flags in the session when the user submits the form.
Finally, we use a submit
server action, which receives the form data (the feature flags settings) and then saves them to the session. After attempting to save the feature flags to the session, we revalidate the page to show the updated feature flags to the user.
And that's it! We are able to securely conditionally render content using a feature flag, update the feature flags securely on the server, and have the application instanly react to changes on the server.
Controlling session feature flags
Conclusion
Thank you for reading this far! I did not expect to go down this journey, but it was worth it for the content. I hope this was helpful, and if I missed anything, let me know on X (f.k.a Twitter). 'Til the next one!
Side note: I was tempted to call this the "Conditional Render Attack" (CRA) to be punny - it rhymes with the other CRA - but maybe this is not so big an issue as that name makes it out to be!