Designing Patterns to Prevent IDOR

mortensonsam1 pts0 comments

Designing Patterns to Prevent IDOR | SevHunt

Skip to main content<br>As a bug bounty triager and researcher, the most common type of bug I see is Insecure Direct Object Reference (IDOR). In their simplest form, IDOR bugs occur when a user provides an ID to the server that they do not normally have access to, and the server accepts it without validation.

For the purposes of this blog post, we’ll mostly be thinking about cross-tenant IDOR, where a user with membership to Organization A maliciously passes an ID belonging to Organization B to the backend.

IDOR bugs are prevalent for a few reasons:

Input validation is usually data validation, and doesn’t consider external factors like the current logged in user

Authorization code often asks the question “Can a user hit this route?”, not “Does the user have access to this ID?”

Web frameworks don’t provide ways to completely mitigate IDOR, although Rails and other ORMs can help with querying data

Developers, I suppose in general, don’t view users as malicious

The adoption of UUIDs has made IDOR harder to mass exploit, as unique IDs aren’t guessable in the same way autoincrementing IDs are

Which all lead developers to treat each individual finding as its own failure - if there’s no generic solution to the problem already, it’s much easier to say “The bug is that this route didn’t validate user input” than “The bug is that IDOR is even remotely possible”.

What would these bug fixes look like if we attempted to make IDOR impossible? In this blog post we’ll explore architectural solutions that future-proof web applications from introducing IDOR, using the SevHunt codebase as a case study.

SevHunt uses TypeScript, tRPC, Zod, and Drizzle to serve backend routes from a Node server. The code samples are very TypeScript-y, but hopefully the concepts are applicable to your stack as well.

Here’s a made-up route we’ll use for an example:

// loggedInProcedure provides a tRPC route that makes sure the user is logged in

updateReportStatus: loggedInProcedure

.input(

// This is a Zod schema that validates user input

z.object({

organizationId: z.uuid(),

reportId: z.uuid(),

status: z.enum(["draft", "deleted", "new", "accepted", "fixed", "rejected"]),

}),

.mutation(async ({ ctx, input }) => {

// requireOrgMembershipValid ensures the user is a member of the given org

await requireOrgMembershipValid(ctx.user.id, input.organizationId);

// A Drizzle db query to update a report's status

await db.update(reports).set({

status: input.status

}).where(eq(reports.id, input.reportId))

return {};

}),

Can you spot the IDOR? While the logged in user’s (ctx.user.id’s) membership to the organization (input.organization) is validated, the report to update (input.reportId) is never verified to belong to that same organization.

Instead of manually fixing the route, let’s leave the contents of mutation(...) the same and think of some ideas how to make sure input.reportId is safe to consume.

Preamble - Sourcing the organization ID from the current resource​

Before diving in, you may notice that the example route above could be secured loading the report and using its organizationId column to determine the “current” organization, instead of “deduplicating” this information in input.organizationId.

However, this approach only works for routes that perform actions on a single model/resource. If we consumed an array of report UUIDs to perform a bulk action instead, what would the “current” organization ID be? Would it be acceptable to perform a bulk operation on resources from different tenants in one request? There’s trade-offs when not explicitly providing an organization ID in every request path/payload for sure, but I wouldn’t view removing it as a solution for IDOR.

Idea 1 - Wrap raw values during validation​

We use Zod to validate user input, for example to know an incoming string is a valid UUID, but we could also use it to transform input into arbitrary data types. For example, here is a Zod schema that returns a function that can be used with the current user ID to access the input organization ID:

export const zodWrappedOrganizationUuid = () => {

return z

.uuid()

.transform(async (organizationId) => {

// Zod returns a function instead of a value, which obfuscates the raw input from routes consuming it

return async (currentUserId: string): Promisestring> => {

await requireOrgMembershipValid(currentUserId, organizationId);

return organizationId;

};

});

};

It feels a little weird to mix authorization into validation, but the benefit is that the user-provided organization ID cannot be used without also validating the current user’s membership.

We can similarly add Zod schema that enforces that developers provide an organization ID before accessing organization-owned models:

// A minimal TypeScript type to ensure passed tables have these columns

type TableWithId = PgTableTableConfig> & {

id: AnyPgColumn;

organizationId: AnyPgColumn;

};

export const...

user input idor organization organizationid current

Related Articles