Skip to content

Commit

Permalink
fix: Ensure setup parameters are sanitized
Browse files Browse the repository at this point in the history
Signed-off-by: Lucian Buzzo <lucian.buzzo@gmail.com>
  • Loading branch information
LucianBuzzo committed Jan 27, 2023
1 parent 5e167ab commit 0d302ab
Show file tree
Hide file tree
Showing 2 changed files with 303 additions and 8 deletions.
45 changes: 37 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Prisma, PrismaClient } from "@prisma/client";
import difference from "lodash/difference";
import flatMap from "lodash/flatMap";
import map from "lodash/map";
import toPairs from "lodash/toPairs";

const VALID_OPERATIONS = ["SELECT", "UPDATE", "INSERT", "DELETE"] as const;

type Operation = typeof VALID_OPERATIONS[number];
export type Models = Prisma.ModelName;
export interface Ability {
description?: string;
expression?: string;
operation: "SELECT" | "UPDATE" | "INSERT" | "DELETE";
operation: Operation;
model?: Models;
slug?: string;
}
Expand All @@ -27,15 +31,17 @@ export type GetContextFn = () => {
const takeLock = (prisma: PrismaClient) =>
prisma.$executeRawUnsafe("SELECT pg_advisory_xact_lock(2142616474639426746);");

// Sanitize a single string by ensuring the it has only lowercase alpha characters and underscores
const sanitizeSlug = (slug: string) => slug.toLowerCase().replace("-", "_").replace(/[^a-z0-9_]/gi, "");

export const createAbilityName = (model: string, ability: string) => {
return `yates_ability_${model}_${ability}_role`.toLowerCase();
return sanitizeSlug(`yates_ability_${model}_${ability}_role`);
};

export const createRoleName = (name: string) => {
// Esnure the role name only has lowercase alpha characters and underscores
// Ensure the role name only has lowercase alpha characters and underscores
// This also doubles as a check against SQL injection
const normalized = name.toLowerCase().replace("-", "_").replace(/[^a-z_]/g, "");
return `yates_role_${normalized}`;
return sanitizeSlug(`yates_role_${name}`);
};

// This middleware is used to set the role and context for the current user so that RLS can be applied
Expand Down Expand Up @@ -64,14 +70,26 @@ export const setupMiddleware = (prisma: PrismaClient, getContext: GetContextFn)
// Generate model class name from model params (PascalCase to camelCase)
const modelName = params.model.charAt(0).toLowerCase() + params.model.slice(1);

if (context) {
for (const k of Object.keys(context)) {
if (!k.match(/^[a-z_\.]+$/)) {
throw new Error(
`Context variable "${k}" contains invalid characters. Context variables must only contain lowercase letters, numbers, periods and underscores.`,
);
}
if (typeof context[k] !== "number" && typeof context[k] !== "string") {
throw new Error(`Context variable "${k}" must be a string or number. Got ${typeof context[k]}`);
}
}
}

try {
const txResults = await adminClient.$transaction([
// Switch to the user role, We can't use a prepared statement here, due to limitations in PG not allowing prepared statements to be used in SET ROLE
adminClient.$queryRawUnsafe(`SET ROLE ${pgRole}`),
// Now set all the context variables using `set_config` so that they can be used in RLS
...toPairs(context).map(([key, value]) => {
const keySafe = key.replace(/[^a-z_\.]/g, "");
return adminClient.$queryRaw`SELECT set_config(${keySafe}, ${value}, true);`;
return adminClient.$queryRaw`SELECT set_config(${key}, ${value}, true);`;
}),
...[
// Now call original function
Expand Down Expand Up @@ -115,7 +133,7 @@ const setRLS = async (
prisma: PrismaClient,
table: string,
roleName: string,
operation: "SELECT" | "INSERT" | "UPDATE" | "DELETE",
operation: Operation,
expression: string,
) => {
// Check if RLS exists
Expand Down Expand Up @@ -162,6 +180,12 @@ export const createRoles = async ({
const abilities: Partial<ModelAbilities> = {};
// See https://github.com/prisma/prisma/discussions/14777
const models = (prisma as any)._baseDmmf.datamodel.models.map((m: any) => m.name) as Models[];
if (customAbilities) {
const diff = difference(Object.keys(customAbilities), models);
if (diff.length) {
throw new Error(`Invalid models in custom abilities: ${diff.join(", ")}`);
}
}
for (const model of models) {
abilities[model] = {
create: {
Expand Down Expand Up @@ -218,6 +242,11 @@ export const createRoles = async ({

for (const slug in abilities[model as keyof typeof abilities]) {
const ability = abilities[model as keyof typeof abilities]![slug];

if (!VALID_OPERATIONS.includes(ability.operation)) {
throw new Error(`Invalid operation: ${ability.operation}`);
}

const roleName = createAbilityName(model, slug);

// Check if role already exists
Expand Down
266 changes: 266 additions & 0 deletions test/integration/sanitation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { PrismaClient } from "@prisma/client";
import { v4 as uuid } from "uuid";
import { setup, SetupParams } from "../../src";

let adminClient: PrismaClient;

beforeAll(async () => {
adminClient = new PrismaClient();
});

// https://xkcd.com/327/
const BAD_STRING = "Robert'); DROP TABLE STUDENTS; --";

describe("sanitation", () => {
it("should sanitize role names", async () => {
const prisma = new PrismaClient();
const role = BAD_STRING;

await setup({
prisma,
getRoles(abilities) {
return {
[role]: [abilities.Post.read],
};
},
getContext: () => ({
role,
}),
});

const { id: postId } = await adminClient.post.create({
data: {
title: `Test post from ${role}`,
},
});

const post = await prisma.post.findUnique({
where: { id: postId },
});

expect(post?.id).toBe(postId);
});

it("should sanitize ability names", async () => {
const prisma = new PrismaClient();
const role = `USER_${uuid()}`;
const ability = BAD_STRING;
await setup({
prisma,
customAbilities: {
Post: {
[ability]: {
description: "Test Post Read",
operation: "SELECT",
expression: "true",
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post[ability]],
};
},
getContext: () => ({
role,
}),
});

const { id: postId } = await adminClient.post.create({
data: {
title: `Test post from ${role}`,
},
});

const post = await prisma.post.findUnique({
where: { id: postId },
});

expect(post?.id).toBe(postId);
});

it("should sanitize operations", async () => {
const prisma = new PrismaClient();
const role = `USER_${uuid()}`;
const ability = "customAbility";

await expect(
setup({
prisma,
customAbilities: {
Post: {
[ability]: {
description: "Test Post Read",
operation: BAD_STRING as any,
expression: "true",
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post[ability]],
};
},
getContext: () => ({
role,
}),
}),
).rejects.toThrowError("Invalid operation");
});

it("should sanitize model names", async () => {
const prisma = new PrismaClient();
const role = `USER_${uuid()}`;
const ability = "customAbility";

await expect(
setup({
prisma,
customAbilities: {
[BAD_STRING]: {
[ability]: {
description: "Test Post Read",
operation: "SELECT",
expression: "true",
},
},
} as any,
getRoles(abilities) {
return {
[role]: [(abilities as any)[BAD_STRING][ability]],
};
},
getContext: () => ({
role,
}),
}),
).rejects.toThrowError("Invalid models in custom abilities");
});

it("should sanitize custom context keys", async () => {
const prisma = new PrismaClient();

const role = `USER_${uuid()}`;

const postTitle = `Test post from ${role}`;

const config: SetupParams = {
prisma,
customAbilities: {
Post: {
createWithTitle: {
description: "Test Post Create",
operation: "INSERT",
expression: "current_setting('post.title') = title",
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post.createWithTitle, abilities.Post.read],
};
},
getContext: () => {
return {
role,
context: {
[BAD_STRING]: postTitle,
},
};
},
};

await setup(config);

await expect(
prisma.post.create({
data: {
title: BAD_STRING,
},
}),
).rejects.toThrow(`Context variable "${BAD_STRING}" contains invalid characters`);
});

it("should sanitize custom context values", async () => {
const prisma = new PrismaClient();

const role = `USER_${uuid()}`;

const postTitle = `Test post from ${role}`;

const config: SetupParams = {
prisma,
customAbilities: {
Post: {
createWithTitle: {
description: "Test Post Create",
operation: "INSERT",
expression: "current_setting('post.title') = title",
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post.createWithTitle, abilities.Post.read],
};
},
getContext: () => {
return {
role,
context: {
"post.title": BAD_STRING,
},
};
},
};

await setup(config);

// We use a prepared statement to sanitize the value, so the expectation
// is that it would simply not match and fail the RLS check, rather than throwing an error
await expect(
prisma.post.create({
data: {
title: postTitle,
},
}),
).rejects.toThrow("You do not have permission to perform this action");
});

// Note: SQL check expressions are inherently unsafe, so we don't sanitize them
// Postgres will throw an error if the expression is invalid, which gives us some safety, however
// the ideal solution is to use a query builder for the expression
it("should sanitize custom ability expressions", async () => {
const prisma = new PrismaClient();

const role = `USER_${uuid()}`;

const config: SetupParams = {
prisma,
customAbilities: {
Post: {
createWithTitle: {
description: "Test Post Create",
operation: "INSERT",
expression: 'DROP TABLE "Post"',
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post.createWithTitle, abilities.Post.read],
};
},
getContext: () => {
return {
role,
context: {
"post.title": BAD_STRING,
},
};
},
};

await expect(setup(config)).rejects.toThrow("syntax error");
});
});

0 comments on commit 0d302ab

Please sign in to comment.