-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: move to whitelist database table from growthbook #864
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
-- CreateTable | ||
CREATE TABLE "Whitelist" ( | ||
"id" SERIAL NOT NULL, | ||
"email" TEXT NOT NULL, | ||
"expiry" TIMESTAMP(3), | ||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
||
CONSTRAINT "Whitelist_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "Whitelist_email_key" ON "Whitelist"("email"); | ||
|
||
-- CreateIndex | ||
CREATE INDEX "Whitelist_email_idx" ON "Whitelist"("email"); | ||
|
||
-- AlterTable | ||
CREATE TRIGGER update_timestamp BEFORE UPDATE ON "Whitelist" FOR EACH ROW EXECUTE PROCEDURE moddatetime("updatedAt"); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import { | |
applySession, | ||
createMockRequest, | ||
} from "tests/integration/helpers/iron-session" | ||
import { setUpWhitelist } from "tests/integration/helpers/seed" | ||
import { describe, expect, it } from "vitest" | ||
|
||
import { env } from "~/env.mjs" | ||
|
@@ -15,16 +16,17 @@ import { getIpFingerprint, LOCALHOST } from "../utils" | |
describe("auth.email", () => { | ||
let caller: Awaited<ReturnType<typeof emailSessionRouter.createCaller>> | ||
let session: ReturnType<typeof applySession> | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
|
||
beforeEach(async () => { | ||
await resetTables("User", "VerificationToken") | ||
await resetTables("User", "VerificationToken", "Whitelist") | ||
await setUpWhitelist({ email: TEST_VALID_EMAIL }) | ||
session = applySession() | ||
const ctx = createMockRequest(session) | ||
caller = emailSessionRouter.createCaller(ctx) | ||
}) | ||
|
||
describe("login", () => { | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
it("should throw if email is not provided", async () => { | ||
// Act | ||
const result = caller.login({ email: "" }) | ||
|
@@ -72,7 +74,6 @@ describe("auth.email", () => { | |
}) | ||
|
||
describe("verifyOtp", () => { | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
const VALID_OTP = "123456" | ||
const VALID_TOKEN_HASH = createTokenHash(VALID_OTP, TEST_VALID_EMAIL) | ||
const INVALID_OTP = "987643" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import { resetTables } from "tests/integration/helpers/db" | ||
import { setUpWhitelist } from "tests/integration/helpers/seed" | ||
|
||
import { isEmailWhitelisted } from "../whitelist.service" | ||
|
||
describe("whitelist.service", () => { | ||
beforeAll(async () => { | ||
const oneYearFromNow = new Date() | ||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1) | ||
const oneYearAgo = new Date() | ||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) | ||
|
||
await resetTables("Whitelist") | ||
await setUpWhitelist({ email: "[email protected]" }) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearFromNow, | ||
}) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearAgo, | ||
}) | ||
await setUpWhitelist({ email: ".gov.sg" }) | ||
await setUpWhitelist({ | ||
email: "@vendor.com.sg", | ||
}) | ||
await setUpWhitelist({ | ||
email: "@whitelisted.com.sg", | ||
expiry: oneYearFromNow, | ||
}) | ||
await setUpWhitelist({ email: "@expired.sg", expiry: oneYearAgo }) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearAgo, | ||
}) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is whitelisted and expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as not whitelisted if the exact email address is whitelisted and expiry is in the past", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(false) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email domain is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email domain is whitelisted and expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as not whitelisted if the exact email domain is whitelisted and expiry is in the past", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(false) | ||
}) | ||
|
||
it("should show email as whitelisted if the suffix of the email domain is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is expired, but the domain's expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,66 @@ | ||||||
import { TRPCError } from "@trpc/server" | ||||||
|
||||||
import { db } from "../database" | ||||||
|
||||||
export const isEmailWhitelisted = async (email: string) => { | ||||||
const lowercaseEmail = email.toLowerCase() | ||||||
|
||||||
// Step 1: Check if the exact email address is whitelisted | ||||||
const exactMatch = await db | ||||||
.selectFrom("Whitelist") | ||||||
.where("email", "=", lowercaseEmail) | ||||||
.where(({ eb }) => | ||||||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||||||
) | ||||||
.select(["id"]) | ||||||
.executeTakeFirst() | ||||||
|
||||||
if (exactMatch) { | ||||||
return true | ||||||
} | ||||||
|
||||||
// Step 2: Check if the exact email domain is whitelisted | ||||||
const emailParts = lowercaseEmail.split("@") | ||||||
harishv7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
if (emailParts.length !== 2 && !emailParts[1]) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This is the mistake that allowed my bypass, but overall is a bit sus There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adjusted in b102054! I think doing a |
||||||
throw new TRPCError({ | ||||||
code: "BAD_REQUEST", | ||||||
message: "An invalid email was provided", | ||||||
}) | ||||||
} | ||||||
|
||||||
const emailDomain = `@${emailParts[1]}` | ||||||
const domainMatch = await db | ||||||
.selectFrom("Whitelist") | ||||||
.where("email", "=", emailDomain) | ||||||
.where(({ eb }) => | ||||||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||||||
) | ||||||
.select(["id"]) | ||||||
.executeTakeFirst() | ||||||
|
||||||
if (domainMatch) { | ||||||
return true | ||||||
} | ||||||
|
||||||
// Step 3: Check if the suffix of the email domain is whitelisted | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the use case for suffix match? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to whitelist There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bypassed with this POC:
This is because RFC-compliant emails that pass This is a common issue and we wrote a specific validator in |
||||||
const domainParts = emailDomain.split(".") | ||||||
for (let i = 1; i < domainParts.length; i++) { | ||||||
// Suffices should start with a dot (e.g. ".gov.sg") | ||||||
const suffix = `.${domainParts.slice(i).join(".")}` | ||||||
|
||||||
const suffixMatch = await db | ||||||
.selectFrom("Whitelist") | ||||||
.where("email", "=", suffix) | ||||||
.where(({ eb }) => | ||||||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||||||
) | ||||||
.select(["id"]) | ||||||
.executeTakeFirst() | ||||||
|
||||||
if (suffixMatch) { | ||||||
return true | ||||||
} | ||||||
} | ||||||
|
||||||
return false | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,3 @@ | ||
const mockFeatureFlags = new Map<string, unknown>() | ||
mockFeatureFlags.set("whitelisted_users", { | ||
whitelist: ["[email protected]"], | ||
}) | ||
|
||
export { mockFeatureFlags } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this userinput sanitised?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is sanitised via the zod schema of the caller procedure, but sure added an additional guard here in 805eebf.