Skip to content
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: allow sharing of secrets publicly + public page for secret sharing #1923

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId");
const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId");

if (await knex.schema.hasTable(TableName.SecretSharing)) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasOrgIdColumn) t.uuid("orgId").nullable().alter();
if (hasUserIdColumn) t.uuid("userId").nullable().alter();
});
}
}

export async function down(knex: Knex): Promise<void> {
const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId");
const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId");

if (await knex.schema.hasTable(TableName.SecretSharing)) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasOrgIdColumn) t.uuid("orgId").notNullable().alter();
if (hasUserIdColumn) t.uuid("userId").notNullable().alter();
});
}
}
4 changes: 2 additions & 2 deletions backend/src/db/schemas/secret-sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const SecretSharingSchema = z.object({
tag: z.string(),
hashedHex: z.string(),
expiresAt: z.date(),
userId: z.string().uuid(),
orgId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
expiresAfterViews: z.number().nullable().optional()
Expand Down
9 changes: 8 additions & 1 deletion backend/src/server/config/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,15 @@ export const creationLimit: RateLimitOptions = {

// Public endpoints to avoid brute force attacks
export const publicEndpointLimit: RateLimitOptions = {
// Shared Secrets
// Read Shared Secrets
timeWindow: 60 * 1000,
max: () => getRateLimiterConfig().publicEndpointLimit,
keyGenerator: (req) => req.realIp
};

export const publicSecretShareCreationLimit: RateLimitOptions = {
// Create Shared Secrets
timeWindow: 60 * 1000,
max: 5,
keyGenerator: (req) => req.realIp
};
48 changes: 43 additions & 5 deletions backend/src/server/routes/v1/secret-sharing-router.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { z } from "zod";

import { SecretSharingSchema } from "@app/db/schemas";
import { publicEndpointLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import {
publicEndpointLimit,
publicSecretShareCreationLimit,
readLimit,
writeLimit
} from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";

Expand Down Expand Up @@ -72,7 +77,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>

server.route({
method: "POST",
url: "/",
url: "/public",
config: {
rateLimit: writeLimit
},
Expand All @@ -82,9 +87,42 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
iv: z.string(),
tag: z.string(),
hashedHex: z.string(),
expiresAt: z
.string()
.refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
expiresAt: z.string(),
expiresAfterViews: z.number()
}),
response: {
200: z.object({
id: z.string().uuid()
})
}
},
handler: async (req) => {
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({
maidul98 marked this conversation as resolved.
Show resolved Hide resolved
encryptedValue,
iv,
tag,
hashedHex,
expiresAt: new Date(expiresAt),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be validating the value of the expiresAt being passed to this endpoint so that no malicious user could fill up our records with non-expiring secrets (like they set it to 10 years from now or something)

or are we already doing that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, limiting it to 30 days for now.

expiresAfterViews
});
return { id: sharedSecret.id };
}
});

server.route({
method: "POST",
url: "/",
config: {
rateLimit: publicSecretShareCreationLimit
},
schema: {
body: z.object({
encryptedValue: z.string(),
iv: z.string(),
tag: z.string(),
hashedHex: z.string(),
expiresAt: z.string(),
expiresAfterViews: z.number()
}),
response: {
Expand Down
59 changes: 57 additions & 2 deletions backend/src/services/secret-sharing/secret-sharing-service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";

import { TSecretSharingDALFactory } from "./secret-sharing-dal";
import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types";
import {
TCreatePublicSharedSecretDTO,
TCreateSharedSecretDTO,
TDeleteSharedSecretDTO,
TSharedSecretPermission
} from "./secret-sharing-types";

type TSecretSharingServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
Expand Down Expand Up @@ -31,6 +36,24 @@ export const secretSharingServiceFactory = ({
} = createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });

if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}

// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}

// Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext)
if (encryptedValue.length > 13000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}

const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
Expand All @@ -44,6 +67,36 @@ export const secretSharingServiceFactory = ({
return { id: newSharedSecret.id };
};

const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => {
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput;
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}

// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" });
}

// Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext)
if (encryptedValue.length > 13000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}

const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews
});
return { id: newSharedSecret.id };
};

const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
Expand All @@ -54,6 +107,7 @@ export const secretSharingServiceFactory = ({

const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
if (!sharedSecret) return;
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return;
}
Expand All @@ -77,6 +131,7 @@ export const secretSharingServiceFactory = ({

return {
createSharedSecret,
createPublicSharedSecret,
getSharedSecrets,
deleteSharedSecretById,
getActiveSharedSecretByIdAndHashedHex
Expand Down
6 changes: 4 additions & 2 deletions backend/src/services/secret-sharing/secret-sharing-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ export type TSharedSecretPermission = {
orgId: string;
};

export type TCreateSharedSecretDTO = {
export type TCreatePublicSharedSecretDTO = {
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAfterViews: number;
} & TSharedSecretPermission;
};

export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;

export type TDeleteSharedSecretDTO = {
sharedSecretId: string;
Expand Down
Binary file modified docs/images/platform/secret-sharing/public-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion frontend/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export const publicPaths = [
"/login/provider/error", // TODO: change
"/login/sso",
"/admin/signup",
"/shared/secret/[id]"
"/shared/secret/[id]",
"/share-secret"
];

export const languageMap = {
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/hooks/api/secretSharing/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@ export const useCreateSharedSecret = () => {
});
};

export const useCreatePublicSharedSecret = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
const { data } = await apiRequest.post<TSharedSecret>(
"/api/v1/secret-sharing/public",
inputData
);
return data;
},
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"])
});
};

export const useDeleteSharedSecret = () => {
const queryClient = useQueryClient();
return useMutation<
TSharedSecret,
{ message: string },
{ sharedSecretId: string }
>({
return useMutation<TSharedSecret, { message: string }, { sharedSecretId: string }>({
mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => {
const { data } = await apiRequest.delete<TSharedSecret>(
`/api/v1/secret-sharing/${sharedSecretId}`
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/api/secretSharing/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const useGetSharedSecrets = () => {
export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => {
return useQuery<TViewSharedSecretResponse, [string]>({
queryFn: async () => {
if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "" });
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
);
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/pages/share-secret/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Head from "next/head";

import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage";

const ShareNewPublicSecretPage = () => {
return (
<>
<Head>
<title>Securely Share Secrets | Infisical</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="" />
<meta name="og:description" content="" />
</Head>
<div className="dark h-full">
<ShareSecretPublicPage isNewSession />
</div>
</>
);
};

export default ShareNewPublicSecretPage;

ShareNewPublicSecretPage.requireAuth = false;
10 changes: 5 additions & 5 deletions frontend/src/pages/shared/secret/[id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Head from "next/head";

import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage";

const SecretApproval = () => {
const SecretSharedPublicPage = () => {
return (
<>
<Head>
Expand All @@ -12,13 +12,13 @@ const SecretApproval = () => {
<meta property="og:title" content="" />
<meta name="og:description" content="" />
</Head>
<div className="h-full">
<ShareSecretPublicPage />
<div className="dark h-full">
<ShareSecretPublicPage isNewSession={false} />
</div>
</>
);
};

export default SecretApproval;
export default SecretSharedPublicPage;

SecretApproval.requireAuth = false;
SecretSharedPublicPage.requireAuth = false;
Loading
Loading