Compare commits

...

3 Commits

Author SHA1 Message Date
Alex van Andel 4e82266d59
Update packages/features/ee/api-keys/lib/apiKeys.ts
fix hashPart
2023-11-09 15:43:33 +00:00
Alex van Andel 6a28725f6c Test new httpError response 2023-11-01 17:00:51 +00:00
Alex van Andel f528de44ed fix: Better errors / middleware order 2023-11-01 12:21:47 +00:00
15 changed files with 146 additions and 105 deletions

View File

@ -1,7 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import type { NextMiddleware } from "next-api-middleware";
import { redactError } from "@calcom/lib/redactError";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
export const captureErrors: NextMiddleware = async (_req, res, next) => {
try {
@ -10,11 +10,10 @@ export const captureErrors: NextMiddleware = async (_req, res, next) => {
await next();
} catch (error) {
Sentry.captureException(error);
const redactedError = redactError(error);
if (redactedError instanceof Error) {
res.status(400).json({ message: redactedError.message, error: redactedError });
return;
}
res.status(400).json({ message: "Something went wrong", error });
console.error(error);
const serverError = getServerErrorFromUnknown(error);
res.status(serverError.statusCode).json({ message: serverError.message });
}
};

View File

@ -1,13 +1,18 @@
import type { NextMiddleware } from "next-api-middleware";
import { apiKeySchema } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
export const rateLimitApiKey: NextMiddleware = async (req, _res, next) => {
const result = apiKeySchema.safeParse(req.query.apiKey);
// Use basic parsing of the API key to ensure we don't rate limit needlessly
if (!result.success) {
throw new HttpError({ statusCode: 401, message: result.error.issues[0].message });
}
// TODO: Add a way to add trusted api keys
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
identifier: result.data,
rateLimitingType: "api",
});

View File

@ -1,32 +1,38 @@
import type { NextMiddleware } from "next-api-middleware";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { apiKeySchema, hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { isAdminGuard } from "../utils/isAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
export const isExpired = function (date: Date): boolean {
const now = new Date();
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
return true;
}
now.setHours(0, 0, 0, 0);
date.setHours(0, 0, 0, 0);
return now > date;
};
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const { prisma, isCustomPrisma, isAdmin } = req;
const hasValidLicense = await checkLicense(prisma);
if (!hasValidLicense && IS_PRODUCTION)
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
if (!hasValidLicense && IS_PRODUCTION) {
// Internal Error when the CALCOM_LICENSE_KEY is invalid or missing
throw new Error("Invalid or missing CALCOM_LICENSE_KEY environment variable");
}
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
if (isCustomPrisma && isAdmin) {
await next();
return;
}
// Check if the apiKey query param is provided.
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
const result = apiKeySchema.safeParse(req.query.apiKey);
// Use basic parsing of the API key to ensure we don't rate limit needlessly
if (!result.success) {
throw new HttpError({ statusCode: 401, message: result.error.issues[0].message });
}
// remove the prefix from the user provided api_key. If no env set default to "cal_"
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
// Hash the key again before matching against the database records.
@ -35,7 +41,7 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
// If cannot find any api key. Throw a 401 Unauthorized.
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
if (apiKey.expiresAt && isExpired(apiKey.expiresAt)) {
return res.status(401).json({ error: "This apiKey is expired" });
}
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });

View File

@ -34,17 +34,18 @@ const middleware = {
type Middleware = keyof typeof middleware;
const middlewareOrder =
// The order here, determines the order of execution
[
"extendRequest",
"captureErrors",
// - Put customPrismaClient before verifyApiKey always.
"customPrismaClient",
"verifyApiKey",
"rateLimitApiKey",
"addRequestId",
] as Middleware[]; // <-- Provide a list of middleware to call automatically
// The order here, determines the order of execution
const middlewareOrder = [
// register captureErrors first, most important.
"captureErrors",
"extendRequest",
// Rate limit api key without verification
"rateLimitApiKey",
// - Put customPrismaClient before verifyApiKey always.
"customPrismaClient",
"verifyApiKey",
"addRequestId",
] satisfies Middleware[];
const withMiddleware = label(middleware, middlewareOrder);

View File

@ -4,6 +4,7 @@ import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach } from "vitest";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { HttpError } from "@calcom/lib/http-error";
import { isAdminGuard } from "~/lib/utils/isAdmin";
@ -46,10 +47,11 @@ describe("Verify API key", () => {
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
await expect(middleware.fn(req, res, serverNext)).rejects.toThrow(HttpError);
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("statusCode", 401);
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("message", "No apiKey provided");
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should thow an error if no api key is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
@ -68,9 +70,10 @@ describe("Verify API key", () => {
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
await expect(middleware.fn(req, res, serverNext)).rejects.toThrow(HttpError);
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("statusCode", 401);
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("message", "No apiKey provided");
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
});

View File

@ -39,7 +39,8 @@ const availabilitySchema = z
duration: z.number().optional(),
withSource: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.")
.refine((data) => data.dateFrom.isValid() && data.dateTo.isValid(), "Invalid time range given.");
const getEventType = async (id: number) => {
const eventType = await prisma.eventType.findUnique({
@ -154,10 +155,6 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } =
availabilitySchema.parse(query);
if (!dateFrom.isValid() || !dateTo.isValid()) {
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
}
const where: Prisma.UserWhereInput = {};
if (username) where.username = username;
if (userId) where.id = userId;

View File

@ -1,4 +1,5 @@
import { randomBytes, createHash } from "crypto";
import { z } from "zod";
// Hash the API key to check against when veriying it. so we don't have to store the key in plain text.
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
@ -8,3 +9,20 @@ export const generateUniqueAPIKey = (apiKey = randomBytes(16).toString("hex")) =
hashAPIKey(apiKey),
apiKey,
];
export const apiKeySchema = z.string({ required_error: "No apiKey provided" }).refine(
(value) => {
// Check if it starts with process.env.API_KEY_PREFIX
if (!value.startsWith(process.env.API_KEY_PREFIX || "cal_")) {
return false;
}
// Extract the hash part and validate its format
const hashPart = value.slice((process.env.API_KEY_PREFIX || "cal_").length); // Remove prefix
const hashRegex = /^[a-f0-9]{32}$/i; // Regex for a 32-character hexadecimal hash
return hashRegex.test(hashPart);
},
{
message: "Your apiKey is not valid",
}
);

View File

@ -1,8 +1,14 @@
import { TRPCError } from "@calcom/trpc/server";
import { httpError } from "./http-error";
import type { RateLimitHelper } from "./rateLimit";
import { rateLimiter } from "./rateLimit";
const rateLimitExceededError = (secondsToWait: number) =>
httpError({
statusCode: 429,
message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,
cause: new Error("TOO_MANY_REQUESTS"),
});
export async function checkRateLimitAndThrowError({
rateLimitingType = "core",
identifier,
@ -12,9 +18,6 @@ export async function checkRateLimitAndThrowError({
if (remaining < 1) {
const convertToSeconds = (ms: number) => Math.floor(ms / 1000);
const secondsToWait = convertToSeconds(reset - Date.now());
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,
});
throw rateLimitExceededError(secondsToWait);
}
}

View File

@ -1,4 +1,4 @@
import { HttpError } from "./http-error";
import { createFromRequest } from "./http-error";
async function http<T>(path: string, config: RequestInit): Promise<T> {
const request = new Request(path, config);
@ -6,7 +6,7 @@ async function http<T>(path: string, config: RequestInit): Promise<T> {
if (!response.ok) {
const errJson = await response.json();
const err = HttpError.fromRequest(request, {
const err = createFromRequest(request, {
...response,
statusText: errJson.message || response.statusText,
});

View File

@ -1,3 +1,11 @@
type HttpErrorOptions<TCode extends number = number> = {
url?: string;
method?: string;
message?: string;
statusCode: TCode;
cause?: Error;
};
export class HttpError<TCode extends number = number> extends Error {
public readonly cause?: Error;
public readonly statusCode: TCode;
@ -5,7 +13,7 @@ export class HttpError<TCode extends number = number> extends Error {
public readonly url: string | undefined;
public readonly method: string | undefined;
constructor(opts: { url?: string; method?: string; message?: string; statusCode: TCode; cause?: Error }) {
constructor(opts: HttpErrorOptions<TCode>) {
super(opts.message ?? `HTTP Error ${opts.statusCode} `);
Object.setPrototypeOf(this, HttpError.prototype);
@ -21,13 +29,14 @@ export class HttpError<TCode extends number = number> extends Error {
this.stack = opts.cause.stack;
}
}
public static fromRequest(request: Request, response: Response) {
return new HttpError({
message: response.statusText,
url: response.url,
method: request.method,
statusCode: response.status,
});
}
}
export const createFromRequest = (request: Request, response: Response) =>
new HttpError({
message: response.statusText,
url: response.url,
method: request.method,
statusCode: response.status,
});
export const httpError = (props: HttpErrorOptions) => new HttpError(props);

View File

@ -1,5 +1,7 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { httpError } from "../http-error";
type Handlers = {
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
};
@ -7,18 +9,11 @@ type Handlers = {
/** Allows us to split big API handlers by method */
export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => {
const handler = (await handlers[req.method as keyof typeof handlers])?.default;
// auto catch unsupported methods.
if (!handler) {
return res
.status(405)
.json({ message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})` });
}
try {
await handler(req, res);
return;
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
throw httpError({
statusCode: 405,
message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})`,
});
}
await handler(req, res);
};

View File

@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
import { performance } from "./perfObserver";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
@ -15,10 +14,7 @@ export function defaultResponder<T>(f: Handle<T>) {
ok = true;
if (result) res.json(result);
} catch (err) {
console.error(err);
const error = getServerErrorFromUnknown(err);
res.statusCode = error.statusCode;
res.json({ message: error.message });
throw err;
} finally {
performance.mark("End");
performance.measure(`[${ok ? "OK" : "ERROR"}][$1] ${req.method} '${req.url}'`, "Start", "End");

View File

@ -1,9 +1,8 @@
import { PrismaClientKnownRequestError, NotFoundError } from "@prisma/client/runtime/library";
import Stripe from "stripe";
import type { ZodIssue } from "zod";
import { ZodError } from "zod";
import { HttpError } from "../http-error";
import { HttpError, httpError } from "../http-error";
import { redactError } from "../redactError";
function hasName(cause: unknown): cause is { name: string } {
@ -28,54 +27,45 @@ function parseZodErrorIssues(issues: ZodIssue[]): string {
export function getServerErrorFromUnknown(cause: unknown): HttpError {
if (isZodError(cause)) {
console.log("cause", cause);
return new HttpError({
return httpError({
statusCode: 400,
message: parseZodErrorIssues(cause.issues),
cause,
});
}
if (cause instanceof SyntaxError) {
return new HttpError({
return httpError({
statusCode: 500,
message: "Unexpected error, please reach out for our customer support.",
});
}
if (cause instanceof PrismaClientKnownRequestError) {
return getHttpError({ statusCode: 400, cause });
return createHttpError({ statusCode: 400, cause });
}
if (cause instanceof NotFoundError) {
return getHttpError({ statusCode: 404, cause });
}
if (cause instanceof Stripe.errors.StripeInvalidRequestError) {
return getHttpError({ statusCode: 400, cause });
return createHttpError({ statusCode: 404, cause });
}
if (cause instanceof HttpError) {
const redactedCause = redactError(cause);
return {
...redactedCause,
cause: cause.cause,
url: cause.url,
statusCode: cause.statusCode,
method: cause.method,
};
return createHttpError({ statusCode: cause.statusCode, cause });
}
if (cause instanceof Error) {
return getHttpError({ statusCode: 500, cause });
return createHttpError({ statusCode: 500, cause });
}
if (typeof cause === "string") {
// @ts-expect-error https://github.com/tc39/proposal-error-cause
return new Error(cause, { cause });
}
return new HttpError({
return httpError({
statusCode: 500,
message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`,
});
}
function getHttpError<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
function createHttpError<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
const redacted = redactError(cause);
return new HttpError({ statusCode, message: redacted.message, cause: redacted });
return httpError({ statusCode, message: redacted.message, cause: redacted });
}

View File

@ -1,11 +1,29 @@
import { z } from "zod";
const iso8601Schema = z.string().refine(
(value) => {
// Attempt to parse the string as a date
const date = new Date(value);
// Check if the date is valid and the string is in ISO 8601 format
return !isNaN(date.getTime()) && value === date.toISOString();
},
{
message: "The string must be a valid ISO 8601 date string",
}
);
const isStartTimeBeforeEndTime = (startTime: string, endTime: string) => {
const start = new Date(startTime);
const end = new Date(endTime);
return start < end;
};
export const getScheduleSchema = z
.object({
// startTime ISOString
startTime: z.string(),
// endTime ISOString
endTime: z.string(),
// startTime as ISO 8601 string
startTime: iso8601Schema,
// endTime as ISO 8601 string
endTime: iso8601Schema,
// Event type ID
eventTypeId: z.coerce.number().int().optional(),
// Event type slug
@ -34,6 +52,10 @@ export const getScheduleSchema = z
.refine(
(data) => !!data.eventTypeId || (!!data.usernameList && !!data.eventTypeSlug),
"You need to either pass an eventTypeId OR an usernameList/eventTypeSlug combination"
)
.refine(
(data) => isStartTimeBeforeEndTime(data.startTime, data.endTime),
"Start time must be before end time."
);
export const reserveSlotSchema = z

View File

@ -302,9 +302,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
const endTime =
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
if (!startTime.isValid() || !endTime.isValid()) {
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
let currentSeats: CurrentSeats | undefined;
let usersWithCredentials = eventType.users.map((user) => ({