From 2ff211d235b1656fa7007b5b750bc5d9580a5a4f Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 24 Jul 2025 16:37:38 -0400 Subject: [PATCH 1/7] Checkpoint --- backend/src/server/routes/index.ts | 21 +- .../app-connection/app-connection-service.ts | 2 +- .../github/github-connection-fns.ts | 296 ++++++++++++++---- .../github/github-connection-schemas.ts | 22 +- .../github/github-connection-service.ts | 12 +- .../github/github-connection-types.ts | 5 +- .../secret-sync/github/github-sync-fns.ts | 99 +++--- .../services/secret-sync/secret-sync-fns.ts | 10 +- .../services/secret-sync/secret-sync-queue.ts | 14 +- .../appConnections/types/github-connection.ts | 2 + .../GitHubConnectionForm.tsx | 114 ++++++- .../OauthCallbackPage/OauthCallbackPage.tsx | 29 +- .../ProjectsPage/ProjectsPage.tsx | 2 +- 13 files changed, 471 insertions(+), 157 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ea4cf9676..2659b6751 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1039,6 +1039,15 @@ export const registerRoutes = async ( kmsService }); + const gatewayService = gatewayServiceFactory({ + permissionService, + gatewayDAL, + kmsService, + licenseService, + orgGatewayConfigDAL, + keyStore + }); + const secretSyncQueue = secretSyncQueueFactory({ queueService, secretSyncDAL, @@ -1062,7 +1071,8 @@ export const registerRoutes = async ( secretVersionTagV2BridgeDAL, resourceMetadataDAL, appConnectionDAL, - licenseService + licenseService, + gatewayService }); const secretQueueService = secretQueueFactory({ @@ -1481,15 +1491,6 @@ export const registerRoutes = async ( licenseService }); - const gatewayService = gatewayServiceFactory({ - permissionService, - gatewayDAL, - kmsService, - licenseService, - orgGatewayConfigDAL, - keyStore - }); - const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ identityKubernetesAuthDAL, identityOrgMembershipDAL, diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 98c9e5f4d..976d154b3 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -583,7 +583,7 @@ export const appConnectionServiceFactory = ({ deleteAppConnection, connectAppConnectionById, listAvailableAppConnectionsForUser, - github: githubConnectionService(connectAppConnectionById), + github: githubConnectionService(connectAppConnectionById, gatewayService), githubRadar: githubRadarConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 360923e19..2607d8e9e 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,10 +1,15 @@ import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; -import { AxiosResponse } from "axios"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import https from "https"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; +import { request as httpRequest } from "@app/lib/config/request"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; @@ -24,10 +29,14 @@ export const getGitHubConnectionListItem = () => { }; }; -export const getGitHubClient = (appConnection: TGitHubConnection) => { +export const getGitHubClient = ( + appConnection: TGitHubConnection, + octokitOptions: Partial<{ baseUrl: string; request: { agent?: https.Agent } }> +) => { const appCfg = getConfig(); const { method, credentials } = appConnection; + const { baseUrl, request } = octokitOptions; let client: Octokit; @@ -48,12 +57,16 @@ export const getGitHubClient = (appConnection: TGitHubConnection) => { appId, privateKey: appPrivateKey, installationId: credentials.installationId - } + }, + baseUrl, + request }); break; case GitHubConnectionMethod.OAuth: client = new Octokit({ - auth: credentials.accessToken + auth: credentials.accessToken, + baseUrl, + request }); break; default: @@ -65,6 +78,139 @@ export const getGitHubClient = (appConnection: TGitHubConnection) => { return client; }; +export const executeWithGitHubGateway = async ( + appConnection: TGitHubConnection, + gatewayService: Pick, + operation: (client: Octokit) => Promise +): Promise => { + const { + gatewayId, + credentials: { host: hostParam } + } = appConnection; + + const host = hostParam || "api.github.com"; + + if (gatewayId && gatewayService) { + const [targetHost] = await verifyHostInputValidity(host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const agent = new https.Agent({ + servername: targetHost, + rejectUnauthorized: true + }); + + const client = getGitHubClient(appConnection, { + baseUrl: `https://localhost:${proxyPort}`, + request: { agent } + }); + + return operation(client); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: 443, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + } + + // Non-gateway path + const client = getGitHubClient(appConnection, { + baseUrl: `https://${host}` + }); + + return operation(client); +}; + +// For non-octokit requests +export const requestWithGitHubGateway = async ( + appConnection: TGitHubConnectionConfig, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + const { + gatewayId, + credentials: { host: hostParam } + } = appConnection; + + const url = new URL(requestConfig.url as string); + const host = hostParam || url.host || "github.com"; + + if (gatewayId && gatewayService) { + const [targetHost] = await verifyHostInputValidity(host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const proxyAgent = new https.Agent({ + servername: targetHost, + rejectUnauthorized: true + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent: proxyAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await httpRequest.request(finalRequestConfig); + } catch (error) { + const axiosError = error as AxiosError; + logger.error("Error during GitHub gateway request:", axiosError.message, axiosError.response?.data); + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: 443, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + } + + if (!url.host) { + url.protocol = "https:"; + url.host = host; + } + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString() + }; + + return httpRequest.request(finalRequestConfig); +}; + type GitHubOrganization = { login: string; id: number; @@ -76,72 +222,83 @@ type GitHubRepository = { owner: GitHubOrganization; }; -export const getGitHubRepositories = async (appConnection: TGitHubConnection) => { - const client = getGitHubClient(appConnection); +export const getGitHubRepositories = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { + return executeWithGitHubGateway(appConnection, gatewayService, async (client) => { + let repositories: GitHubRepository[]; - let repositories: GitHubRepository[]; + switch (appConnection.method) { + case GitHubConnectionMethod.App: + repositories = await client.paginate("GET /installation/repositories"); + break; + case GitHubConnectionMethod.OAuth: + default: + repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin); + break; + } - switch (appConnection.method) { - case GitHubConnectionMethod.App: - repositories = await client.paginate("GET /installation/repositories"); - break; - case GitHubConnectionMethod.OAuth: - default: - repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin); - break; - } - - return repositories; + return repositories; + }); }; -export const getGitHubOrganizations = async (appConnection: TGitHubConnection) => { - const client = getGitHubClient(appConnection); +export const getGitHubOrganizations = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { + return executeWithGitHubGateway(appConnection, gatewayService, async (client) => { + let organizations: GitHubOrganization[]; - let organizations: GitHubOrganization[]; + switch (appConnection.method) { + case GitHubConnectionMethod.App: { + const installationRepositories = await client.paginate("GET /installation/repositories"); - switch (appConnection.method) { - case GitHubConnectionMethod.App: { - const installationRepositories = await client.paginate("GET /installation/repositories"); + const organizationMap: Record = {}; - const organizationMap: Record = {}; + installationRepositories.forEach((repo) => { + if (repo.owner.type === "Organization") { + organizationMap[repo.owner.id] = repo.owner; + } + }); - installationRepositories.forEach((repo) => { - if (repo.owner.type === "Organization") { - organizationMap[repo.owner.id] = repo.owner; - } + organizations = Object.values(organizationMap); + + break; + } + case GitHubConnectionMethod.OAuth: + default: + organizations = await client.paginate("GET /user/orgs"); + break; + } + + return organizations; + }); +}; + +export const getGitHubEnvironments = async ( + appConnection: TGitHubConnection, + gatewayService: Pick, + owner: string, + repo: string +) => { + return executeWithGitHubGateway(appConnection, gatewayService, async (client) => { + try { + const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", { + owner, + repo }); - organizations = Object.values(organizationMap); + return environments; + } catch (e) { + // repo doesn't have envs + if ((e as { status: number }).status === 404) { + return []; + } - break; + throw e; } - case GitHubConnectionMethod.OAuth: - default: - organizations = await client.paginate("GET /user/orgs"); - break; - } - - return organizations; -}; - -export const getGitHubEnvironments = async (appConnection: TGitHubConnection, owner: string, repo: string) => { - const client = getGitHubClient(appConnection); - - try { - const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", { - owner, - repo - }); - - return environments; - } catch (e) { - // repo doesn't have envs - if ((e as { status: number }).status === 404) { - return []; - } - - throw e; - } + }); }; export type GithubTokenRespData = { @@ -159,9 +316,11 @@ export function isGithubErrorResponse(data: GithubTokenRespData): data is Github return "error" in data; } -export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { +export const validateGitHubConnectionCredentials = async ( + config: TGitHubConnectionConfig, + gatewayService: Pick +) => { const { credentials, method } = config; - const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, @@ -192,10 +351,14 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } let tokenResp: AxiosResponse; + const host = credentials.host || "github.com"; + const apiHost = credentials.host ? `api.${credentials.host}` : "api.github.com"; try { - tokenResp = await request.get("https://github.com/login/oauth/access_token", { - params: { + tokenResp = await requestWithGitHubGateway(config, gatewayService, { + url: `https://${host}/login/oauth/access_token`, + method: "POST", + data: { client_id: clientId, client_secret: clientSecret, code: credentials.code, @@ -203,7 +366,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }, headers: { Accept: "application/json", - "Accept-Encoding": "application/json" + "Content-Type": "application/json" } }); @@ -233,7 +396,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); } - const installationsResp = await request.get<{ + const installationsResp = await requestWithGitHubGateway<{ installations: { id: number; account: { @@ -242,7 +405,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect id: number; }; }[]; - }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + }>(config, gatewayService, { + url: IntegrationUrls.GITHUB_USER_INSTALLATIONS.replace("api.github.com", apiHost), headers: { Accept: "application/json", Authorization: `Bearer ${tokenResp.data.access_token}`, diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index e98b9169d..bf92ec155 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -11,20 +11,24 @@ import { import { GitHubConnectionMethod } from "./github-connection-enums"; export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ - code: z.string().trim().min(1, "OAuth code required") + code: z.string().trim().min(1, "OAuth code required"), + host: z.string().trim().optional() }); export const GitHubConnectionAppInputCredentialsSchema = z.object({ code: z.string().trim().min(1, "GitHub App code required"), - installationId: z.string().min(1, "GitHub App Installation ID required") + installationId: z.string().min(1, "GitHub App Installation ID required"), + host: z.string().trim().optional() }); export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ - accessToken: z.string() + accessToken: z.string(), + host: z.string().trim().optional() }); export const GitHubConnectionAppOutputCredentialsSchema = z.object({ - installationId: z.string() + installationId: z.string(), + host: z.string().trim().optional() }); export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -43,7 +47,9 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m ]); export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub) + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub, { + supportsGateways: true + }) ); export const UpdateGitHubConnectionSchema = z @@ -53,7 +59,11 @@ export const UpdateGitHubConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub)); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub, { + supportsGateways: true + }) + ); const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); diff --git a/backend/src/services/app-connection/github/github-connection-service.ts b/backend/src/services/app-connection/github/github-connection-service.ts index b4e95c5a7..f1198ddfa 100644 --- a/backend/src/services/app-connection/github/github-connection-service.ts +++ b/backend/src/services/app-connection/github/github-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { @@ -19,11 +20,14 @@ type TListGitHubEnvironmentsDTO = { owner: string; }; -export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const githubConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const repositories = await getGitHubRepositories(appConnection); + const repositories = await getGitHubRepositories(appConnection, gatewayService); return repositories; }; @@ -31,7 +35,7 @@ export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const organizations = await getGitHubOrganizations(appConnection); + const organizations = await getGitHubOrganizations(appConnection, gatewayService); return organizations; }; @@ -42,7 +46,7 @@ export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) ) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const environments = await getGitHubEnvironments(appConnection, owner, repo); + const environments = await getGitHubEnvironments(appConnection, gatewayService, owner, repo); return environments; }; diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 600506277..c4aed54b5 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -17,4 +17,7 @@ export type TGitHubConnectionInput = z.infer; +export type TGitHubConnectionConfig = DiscriminativePick< + TGitHubConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +>; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index f06f0cfc2..59affa2b7 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -1,7 +1,8 @@ import { Octokit } from "@octokit/rest"; import sodium from "libsodium-wrappers"; -import { getGitHubClient } from "@app/services/app-connection/github"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { executeWithGitHubGateway } from "@app/services/app-connection/github"; import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; @@ -160,7 +161,11 @@ const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials }; export const GithubSyncFns = { - syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: TGitHubSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { switch (secretSync.destinationConfig.scope) { case GitHubSyncScope.Organization: if (Object.values(secretMap).length > 1000) { @@ -187,63 +192,67 @@ export const GithubSyncFns = { ); } - const client = getGitHubClient(secretSync.connection); + await executeWithGitHubGateway(secretSync.connection, gatewayService, async (client) => { + const encryptedSecrets = await getEncryptedSecrets(client, secretSync); - const encryptedSecrets = await getEncryptedSecrets(client, secretSync); + const publicKey = await getPublicKey(client, secretSync); - const publicKey = await getPublicKey(client, secretSync); + await sodium.ready.then(async () => { + for await (const key of Object.keys(secretMap)) { + // convert secret & base64 key to Uint8Array. + const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); + const binarySecretValue = sodium.from_string(secretMap[key].value); - await sodium.ready.then(async () => { - for await (const key of Object.keys(secretMap)) { - // convert secret & base64 key to Uint8Array. - const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); - const binarySecretValue = sodium.from_string(secretMap[key].value); + // encrypt secret using libsodium + const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey); - // encrypt secret using libsodium - const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey); + // convert encrypted Uint8Array to base64 + const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); - // convert encrypted Uint8Array to base64 - const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); + try { + await putSecret(client, secretSync, { + secret_name: key, + encrypted_value: encryptedSecretValue, + key_id: publicKey.key_id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }); - try { - await putSecret(client, secretSync, { - secret_name: key, - encrypted_value: encryptedSecretValue, - key_id: publicKey.key_id - }); - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const encryptedSecret of encryptedSecrets) { + if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; + + if (!(encryptedSecret.name in secretMap)) { + await deleteSecret(client, secretSync, encryptedSecret); } } }); - - if (secretSync.syncOptions.disableSecretDeletion) return; - - for await (const encryptedSecret of encryptedSecrets) { - if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) - // eslint-disable-next-line no-continue - continue; - - if (!(encryptedSecret.name in secretMap)) { - await deleteSecret(client, secretSync, encryptedSecret); - } - } }, getSecrets: async (secretSync: TGitHubSyncWithCredentials) => { throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); }, - removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { - const client = getGitHubClient(secretSync.connection); + removeSecrets: async ( + secretSync: TGitHubSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { + await executeWithGitHubGateway(secretSync.connection, gatewayService, async (client) => { + const encryptedSecrets = await getEncryptedSecrets(client, secretSync); - const encryptedSecrets = await getEncryptedSecrets(client, secretSync); - - for await (const encryptedSecret of encryptedSecrets) { - if (encryptedSecret.name in secretMap) { - await deleteSecret(client, secretSync, encryptedSecret); + for await (const encryptedSecret of encryptedSecrets) { + if (encryptedSecret.name in secretMap) { + await deleteSecret(client, secretSync, encryptedSecret); + } } - } + }); } }; diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index caa6ddcee..6fa46f1e6 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,6 +1,7 @@ import { AxiosError } from "axios"; import handlebars from "handlebars"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; import { BadRequestError } from "@app/lib/errors"; @@ -97,6 +98,7 @@ export const listSecretSyncOptions = () => { type TSyncSecretDeps = { appConnectionDAL: Pick; kmsService: Pick; + gatewayService: Pick; }; // Add schema to secret keys @@ -191,7 +193,7 @@ export const SecretSyncFns = { syncSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -201,7 +203,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap); + return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.GCPSecretManager: return GcpSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: @@ -395,7 +397,7 @@ export const SecretSyncFns = { removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -405,7 +407,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap); + return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.GCPSecretManager: return GcpSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 5d788ea88..7bef7d8c7 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -4,6 +4,7 @@ import { Job } from "bullmq"; import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; @@ -96,6 +97,7 @@ type TSecretSyncQueueFactoryDep = { resourceMetadataDAL: Pick; folderCommitService: Pick; licenseService: Pick; + gatewayService: Pick; }; type SecretSyncActionJob = Job< @@ -138,7 +140,8 @@ export const secretSyncQueueFactory = ({ secretVersionTagV2BridgeDAL, resourceMetadataDAL, folderCommitService, - licenseService + licenseService, + gatewayService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -353,7 +356,8 @@ export const secretSyncQueueFactory = ({ const importedSecrets = await SecretSyncFns.getSecrets(secretSync, { appConnectionDAL, - kmsService + kmsService, + gatewayService }); if (!Object.keys(importedSecrets).length) return {}; @@ -481,7 +485,8 @@ export const secretSyncQueueFactory = ({ await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap, { appConnectionDAL, - kmsService + kmsService, + gatewayService }); isSynced = true; @@ -730,7 +735,8 @@ export const secretSyncQueueFactory = ({ secretMap, { appConnectionDAL, - kmsService + kmsService, + gatewayService } ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts index d00936cda..a8b734aa9 100644 --- a/frontend/src/hooks/api/appConnections/types/github-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -11,6 +11,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub method: GitHubConnectionMethod.OAuth; credentials: { code: string; + host?: string; }; } | { @@ -18,6 +19,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub credentials: { code: string; installationId: string; + host?: string; }; } ); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx index ea64d1751..4e4c3bffd 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -3,11 +3,30 @@ import crypto from "crypto"; import { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; -import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + ModalClose, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { isInfisicalCloud } from "@app/helpers/platform"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { GitHubConnectionMethod, TGitHubConnection, @@ -26,7 +45,10 @@ type Props = { const formSchema = genericAppConnectionFieldsSchema.extend({ app: z.literal(AppConnection.GitHub), - method: z.nativeEnum(GitHubConnectionMethod) + method: z.nativeEnum(GitHubConnectionMethod), + credentials: z.object({ + host: z.string().optional() + }) }); type FormData = z.infer; @@ -44,7 +66,8 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => { resolver: zodResolver(formSchema), defaultValues: appConnection ?? { app: AppConnection.GitHub, - method: GitHubConnectionMethod.App + method: GitHubConnectionMethod.App, + gatewayId: null } }); @@ -55,6 +78,8 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => { formState: { isSubmitting, isDirty } } = form; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const selectedMethod = watch("method"); const onSubmit = (formData: FormData) => { @@ -66,15 +91,20 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => { JSON.stringify({ ...formData, connectionId: appConnection?.id }) ); + const githubHost = + formData.credentials.host && formData.credentials.host.length > 0 + ? `https://${formData.credentials.host}` + : "https://github.com"; + switch (formData.method) { case GitHubConnectionMethod.App: window.location.assign( - `https://github.com/apps/${appClientSlug}/installations/new?state=${state}` + `${githubHost}/apps/${appClientSlug}/installations/new?state=${state}` ); break; case GitHubConnectionMethod.OAuth: window.location.assign( - `https://github.com/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/organization/app-connections/github/oauth/callback&state=${state}` + `${githubHost}/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/organization/app-connections/github/oauth/callback&state=${state}` ); break; default: @@ -141,6 +171,80 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => { )} /> + + + +
GitHub Enterprise Options
+
+ + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+ ( + + + + )} + /> +
+
+
+
+ + + )} + /> + )} + + )} { formState: { isSubmitting, isDirty } } = form; + const { subscription } = useSubscription(); const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); @@ -96,55 +98,57 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } - - {(isAllowed) => ( - ( - - + {(isAllowed) => ( + ( + -
- - Internet Gateway - - {gateways?.map((el) => ( - - {el.name} + onChange(undefined)} + > + Internet Gateway - ))} - -
-
-
- )} - /> - )} -
+ {gateways?.map((el) => ( + + {el.name} + + ))} + + + + + )} + /> + )} + + )} { formState: { isSubmitting, isDirty } } = form; + const { subscription } = useSubscription(); const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); @@ -96,55 +98,57 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } - - {(isAllowed) => ( - ( - - + {(isAllowed) => ( + ( + -
- - Internet Gateway - - {gateways?.map((el) => ( - - {el.name} + onChange(undefined)} + > + Internet Gateway - ))} - -
-
-
- )} - /> - )} -
+ {gateways?.map((el) => ( + + {el.name} + + ))} + + + + + )} + /> + )} + + )} { formState: { isSubmitting, isDirty } } = form; + const { subscription } = useSubscription(); const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); @@ -96,55 +98,57 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } - - {(isAllowed) => ( - ( - - + {(isAllowed) => ( + ( + -
- - Internet Gateway - - {gateways?.map((el) => ( - - {el.name} + onChange(undefined)} + > + Internet Gateway - ))} - -
-
-
- )} - /> - )} -
+ {gateways?.map((el) => ( + + {el.name} + + ))} + + + + + )} + /> + )} + + )} Date: Thu, 24 Jul 2025 22:53:49 -0400 Subject: [PATCH 6/7] Validate hostname --- .../services/app-connection/github/github-connection-fns.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index b86e2ed65..b38b9406b 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -10,6 +10,7 @@ import { request as httpRequest } from "@app/lib/config/request"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; @@ -30,7 +31,7 @@ export const getGitHubConnectionListItem = () => { }; export const requestWithGitHubGateway = async ( - appConnection: { gatewayId?: string | null; credentials: { host?: string } }, + appConnection: { gatewayId?: string | null }, gatewayService: Pick, requestConfig: AxiosRequestConfig ): Promise> => { @@ -43,6 +44,8 @@ export const requestWithGitHubGateway = async ( const url = new URL(requestConfig.url as string); + await blockLocalAndPrivateIpAddresses(url.toString()); + const [targetHost] = await verifyHostInputValidity(url.host, true); const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); From 0b7b32bdc367a4c712d580bcacf38fe6fa12af59 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 25 Jul 2025 16:55:21 -0400 Subject: [PATCH 7/7] Add proper URI component encoding + hostname check --- backend/src/lib/validator/validate-url.ts | 5 ++++ .../github/github-connection-fns.ts | 2 +- .../secret-sync/github/github-sync-fns.ts | 24 +++++++++---------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index 8f195e0b5..a4c07b37d 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -14,6 +14,11 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { if (appCfg.isDevelopmentMode) return; const validUrl = new URL(url); + + if (validUrl.username || validUrl.password) { + throw new BadRequestError({ message: "URLs with user credentials (e.g., user:pass@) are not allowed" }); + } + const inputHostIps: string[] = []; if (isIPv4(validUrl.hostname)) { inputHostIps.push(validUrl.hostname); diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index b38b9406b..57d01be29 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -241,7 +241,7 @@ export const getGitHubEnvironments = async ( return await makePaginatedGitHubRequest( appConnection, gatewayService, - `/repos/${owner}/${repo}/environments`, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/environments`, (data) => data.environments ); } catch (error) { diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 022490da6..b37d5e90e 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -26,16 +26,16 @@ const getEncryptedSecrets = async ( let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - path = `/orgs/${destinationConfig.org}/actions/secrets`; + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets`; break; } case GitHubSyncScope.Repository: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/actions/secrets`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/environments/${destinationConfig.env}/secrets`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets`; break; } } @@ -58,16 +58,16 @@ const getPublicKey = async ( let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - path = `/orgs/${destinationConfig.org}/actions/secrets/public-key`; + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/public-key`; break; } case GitHubSyncScope.Repository: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/actions/secrets/public-key`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/public-key`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/environments/${destinationConfig.env}/secrets/public-key`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/public-key`; break; } } @@ -96,16 +96,16 @@ const deleteSecret = async ( let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - path = `/orgs/${destinationConfig.org}/actions/secrets/${encryptedSecret.name}`; + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } case GitHubSyncScope.Repository: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/actions/secrets/${encryptedSecret.name}`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/environments/${destinationConfig.env}/secrets/${encryptedSecret.name}`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } } @@ -135,7 +135,7 @@ const putSecret = async ( switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { const { visibility, selectedRepositoryIds } = destinationConfig; - path = `/orgs/${destinationConfig.org}/actions/secrets/${payload.secret_name}`; + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/${encodeURIComponent(payload.secret_name)}`; body = { ...payload, visibility, @@ -146,12 +146,12 @@ const putSecret = async ( break; } case GitHubSyncScope.Repository: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/actions/secrets/${payload.secret_name}`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/${encodeURIComponent(payload.secret_name)}`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - path = `/repos/${destinationConfig.owner}/${destinationConfig.repo}/environments/${destinationConfig.env}/secrets/${payload.secret_name}`; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/${encodeURIComponent(payload.secret_name)}`; break; } }