mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat(open-swe): encrypt GitHub tokens in proxy route to prevent exposure in LangGraph runs (#160)
* Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * cr * docs
This commit is contained in:
parent
6c23f85eef
commit
fda6529419
9 changed files with 294 additions and 16 deletions
16
README.md
16
README.md
|
|
@ -29,7 +29,7 @@ cp ./apps/open-swe/.env.example ./apps/open-swe/.env
|
|||
cp ./apps/web/.env.example ./apps/web/.env
|
||||
```
|
||||
|
||||
The agent `.env` file should contain the following variables:
|
||||
The open-swe `.env` file should contain the following variables:
|
||||
|
||||
```bash
|
||||
# ------------------LangSmith tracing------------------
|
||||
|
|
@ -45,12 +45,17 @@ GOOGLE_API_KEY=""
|
|||
|
||||
# Daytona API key for accessing and modifying the code in the cloud sandbox.
|
||||
DAYTONA_API_KEY=""
|
||||
|
||||
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
|
||||
# Should be the same value as the one used in the web app.
|
||||
# Can be generated via: `openssl rand -hex 32`
|
||||
GITHUB_TOKEN_ENCRYPTION_KEY=""
|
||||
```
|
||||
|
||||
And the web `.env` file should contain the following variables:
|
||||
|
||||
```bash
|
||||
# Change to production URLs when deployed
|
||||
# Change both to production URLs when deployed
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
|
||||
LANGGRAPH_API_URL="http://localhost:2024"
|
||||
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
|
||||
|
|
@ -63,8 +68,15 @@ GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
|
|||
GITHUB_APP_NAME="open-swe-dev"
|
||||
GITHUB_APP_ID=""
|
||||
GITHUB_APP_PRIVATE_KEY=""
|
||||
|
||||
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
|
||||
# Should be the same value as the one used in the open-swe app.
|
||||
# Can be generated via: `openssl rand -hex 32`
|
||||
GITHUB_TOKEN_ENCRYPTION_KEY=""
|
||||
```
|
||||
|
||||
**REMINDER**: The `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable must be the same in both the web and open-swe apps.
|
||||
|
||||
To get the GitHub App secrets, first create a new GitHub app (note: this is not the same as the OAuth app) in [the developer settings](https://github.com/settings/apps/new).
|
||||
|
||||
Give the app a name and description.
|
||||
|
|
|
|||
|
|
@ -11,3 +11,7 @@ GOOGLE_API_KEY=""
|
|||
|
||||
# Daytona API key for accessing and modifying the code in the cloud sandbox.
|
||||
DAYTONA_API_KEY=""
|
||||
|
||||
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
|
||||
# Should be the same value as the one used in the web app.
|
||||
GITHUB_TOKEN_ENCRYPTION_KEY=""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth";
|
||||
import { verifyGithubUser, GithubUser } from "./github-auth.js";
|
||||
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
|
||||
import {
|
||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
} from "@open-swe/shared/constants";
|
||||
import { decryptGitHubToken } from "@open-swe/shared/crypto";
|
||||
|
||||
const STUDIO_USER_ID = "langgraph-studio-user";
|
||||
|
||||
|
|
@ -38,18 +42,38 @@ export const auth = new Auth()
|
|||
display_name: "CORS Preflight",
|
||||
};
|
||||
}
|
||||
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.",
|
||||
);
|
||||
}
|
||||
|
||||
// Parse Authorization header
|
||||
const accessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
|
||||
if (!accessToken) {
|
||||
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
|
||||
if (!encryptedAccessToken) {
|
||||
throw new HTTPException(401, {
|
||||
message: "GitHub access token header missing",
|
||||
});
|
||||
}
|
||||
// We don't do anything with this token right now, but still confirm it
|
||||
// exists as it will cause issues later on if it's not present.
|
||||
const encryptedInstallationToken = request.headers.get(
|
||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||
);
|
||||
if (!encryptedInstallationToken) {
|
||||
throw new HTTPException(401, {
|
||||
message: "GitHub installation token header missing",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate GitHub access token
|
||||
let user: GithubUser | undefined;
|
||||
try {
|
||||
user = await verifyGithubUser(accessToken);
|
||||
// Ensure we decrypt the token before passing to the verification function.
|
||||
user = await verifyGithubUser(
|
||||
decryptGitHubToken(encryptedAccessToken, encryptionKey),
|
||||
);
|
||||
if (!user) {
|
||||
throw new HTTPException(401, {
|
||||
message:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,43 @@
|
|||
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
|
||||
import {
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||
} from "@open-swe/shared/constants";
|
||||
import { GraphConfig } from "@open-swe/shared/open-swe/types";
|
||||
import { decryptGitHubToken } from "@open-swe/shared/crypto";
|
||||
|
||||
export function getGitHubTokensFromConfig(config: GraphConfig): {
|
||||
githubAccessToken: string;
|
||||
githubInstallationToken: string;
|
||||
} {
|
||||
if (!config.configurable) {
|
||||
throw new Error("No configurable object found in graph config.");
|
||||
}
|
||||
const githubAccessToken = config.configurable[GITHUB_TOKEN_COOKIE];
|
||||
if (!githubAccessToken) {
|
||||
throw new Error("Missing required x-github-access-token in configuration.");
|
||||
const encryptedGitHubToken = config.configurable[GITHUB_TOKEN_COOKIE];
|
||||
const encryptedInstallationToken =
|
||||
config.configurable[GITHUB_INSTALLATION_TOKEN_COOKIE];
|
||||
if (!encryptedGitHubToken || !encryptedInstallationToken) {
|
||||
throw new Error(
|
||||
"Missing required x-github-access-token or x-github-installation-token in configuration.",
|
||||
);
|
||||
}
|
||||
return { githubAccessToken };
|
||||
|
||||
// Get the encryption key from environment variables
|
||||
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.",
|
||||
);
|
||||
}
|
||||
|
||||
// Decrypt the GitHub token
|
||||
const githubAccessToken = decryptGitHubToken(
|
||||
encryptedGitHubToken,
|
||||
encryptionKey,
|
||||
);
|
||||
const githubInstallationToken = decryptGitHubToken(
|
||||
encryptedInstallationToken,
|
||||
encryptionKey,
|
||||
);
|
||||
|
||||
return { githubAccessToken, githubInstallationToken };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# Change both to production URLs when deployed
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
|
||||
LANGGRAPH_API_URL="http://localhost:2024"
|
||||
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
|
||||
|
|
@ -10,3 +11,8 @@ GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
|
|||
GITHUB_APP_NAME="open-swe-dev"
|
||||
GITHUB_APP_ID=""
|
||||
GITHUB_APP_PRIVATE_KEY=""
|
||||
|
||||
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
|
||||
# Should be the same value as the one used in the open-swe app.
|
||||
# Can be generated via: `openssl rand -hex 32`
|
||||
GITHUB_TOKEN_ENCRYPTION_KEY=""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,50 @@
|
|||
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough";
|
||||
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
|
||||
import {
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||
} from "@open-swe/shared/constants";
|
||||
import { encryptGitHubToken } from "@open-swe/shared/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getInstallationToken } from "@/utils/github";
|
||||
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
|
||||
|
||||
function getGitHubAccessTokenOrThrow(
|
||||
req: NextRequest,
|
||||
encryptionKey: string,
|
||||
): string {
|
||||
const token = req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "";
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No GitHub access token cookie found.");
|
||||
}
|
||||
|
||||
return encryptGitHubToken(token, encryptionKey);
|
||||
}
|
||||
|
||||
async function getGitHubInstallationTokenOrThrow(
|
||||
req: NextRequest,
|
||||
encryptionKey: string,
|
||||
): Promise<string> {
|
||||
const installationIdCookie = req.cookies.get(
|
||||
GITHUB_INSTALLATION_ID_COOKIE,
|
||||
)?.value;
|
||||
if (!installationIdCookie) {
|
||||
throw new Error("No GitHub installation ID cookie found.");
|
||||
}
|
||||
|
||||
const appId = process.env.GITHUB_APP_ID;
|
||||
const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
if (!appId || !privateAppKey) {
|
||||
throw new Error("GitHub App ID or Private App Key is not configured.");
|
||||
}
|
||||
|
||||
const token = await getInstallationToken(
|
||||
installationIdCookie,
|
||||
appId,
|
||||
privateAppKey,
|
||||
);
|
||||
return encryptGitHubToken(token, encryptionKey);
|
||||
}
|
||||
|
||||
// This file acts as a proxy for requests to your LangGraph server.
|
||||
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
|
||||
|
|
@ -9,10 +54,18 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
|
|||
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
||||
runtime: "edge", // default
|
||||
disableWarningLog: true,
|
||||
headers: (req) => {
|
||||
headers: async (req) => {
|
||||
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
throw new Error(
|
||||
"GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[GITHUB_TOKEN_COOKIE]:
|
||||
req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "",
|
||||
[GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey),
|
||||
[GITHUB_INSTALLATION_TOKEN_COOKIE]:
|
||||
await getGitHubInstallationTokenOrThrow(req, encryptionKey),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan";
|
|||
|
||||
// Prefix the access token with `x-` so that it's included in requests to the LangGraph server.
|
||||
export const GITHUB_TOKEN_COOKIE = "x-github-access-token";
|
||||
export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token";
|
||||
|
|
|
|||
132
packages/shared/src/crypto.ts
Normal file
132
packages/shared/src/crypto.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import * as crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Encryption utility for GitHub tokens using AES-256-GCM
|
||||
*
|
||||
* This module provides secure encryption and decryption of GitHub access tokens
|
||||
* using AES-256-GCM encryption with authenticated encryption.
|
||||
*/
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12; // 96 bits (Standard for GCM)
|
||||
const TAG_LENGTH = 16; // 128 bits
|
||||
|
||||
/**
|
||||
* Derives a 256-bit key from the provided encryption key string
|
||||
* Uses SHA-256 to ensure consistent key length
|
||||
*/
|
||||
function deriveKey(encryptionKey: string): Buffer {
|
||||
return crypto.createHash("sha256").update(encryptionKey).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a GitHub token using AES-256-GCM
|
||||
*
|
||||
* @param token - The GitHub access token to encrypt
|
||||
* @param encryptionKey - The encryption key (will be hashed to 256 bits)
|
||||
* @returns Base64 encoded encrypted data containing IV, encrypted token, and auth tag
|
||||
* @throws Error if encryption fails or inputs are invalid
|
||||
*/
|
||||
export function encryptGitHubToken(
|
||||
token: string,
|
||||
encryptionKey: string,
|
||||
): string {
|
||||
if (!token || typeof token !== "string") {
|
||||
throw new Error("Token must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!encryptionKey || typeof encryptionKey !== "string") {
|
||||
throw new Error("Encryption key must be a non-empty string");
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate a random IV for each encryption (12 bytes for GCM)
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Derive the encryption key
|
||||
const key = deriveKey(encryptionKey);
|
||||
|
||||
// Create cipher
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
// Encrypt the token
|
||||
const encryptedBuffer = Buffer.concat([
|
||||
cipher.update(token, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
|
||||
// Get the authentication tag
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Combine IV, encrypted data, and tag into a single base64 string
|
||||
// Format: IV (12 bytes) + EncryptedData + AuthTag (16 bytes)
|
||||
const combined = Buffer.concat([iv, encryptedBuffer, tag]);
|
||||
return combined.toString("base64");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to encrypt token: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a GitHub token using AES-256-GCM
|
||||
*
|
||||
* @param encryptedToken - Base64 encoded encrypted data from encryptGitHubToken
|
||||
* @param encryptionKey - The encryption key used for encryption
|
||||
* @returns The decrypted GitHub access token
|
||||
* @throws Error if decryption fails or inputs are invalid
|
||||
*/
|
||||
export function decryptGitHubToken(
|
||||
encryptedToken: string,
|
||||
encryptionKey: string,
|
||||
): string {
|
||||
if (!encryptedToken || typeof encryptedToken !== "string") {
|
||||
throw new Error("Encrypted token must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!encryptionKey || typeof encryptionKey !== "string") {
|
||||
throw new Error("Encryption key must be a non-empty string");
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode the combined data
|
||||
const combined = Buffer.from(encryptedToken, "base64");
|
||||
|
||||
// Minimum length: IV_LENGTH + TAG_LENGTH + 1 byte for data
|
||||
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
|
||||
throw new Error("Invalid encrypted token format: too short or malformed");
|
||||
}
|
||||
|
||||
// Extract IV, encrypted data, and tag
|
||||
// IV is first IV_LENGTH bytes
|
||||
// AuthTag is last TAG_LENGTH bytes
|
||||
// Encrypted data is in between
|
||||
const iv = combined.subarray(0, IV_LENGTH);
|
||||
const tag = combined.subarray(combined.length - TAG_LENGTH);
|
||||
const encrypted = combined.subarray(
|
||||
IV_LENGTH,
|
||||
combined.length - TAG_LENGTH,
|
||||
);
|
||||
|
||||
// Derive the encryption key
|
||||
const key = deriveKey(encryptionKey);
|
||||
|
||||
// Create decipher
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
// Decrypt the token
|
||||
// 'encrypted' is a Buffer, so no input encoding is specified for update()
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decryptedBuffer.toString("utf8");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to decrypt token: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,10 @@ import {
|
|||
type UIMessage,
|
||||
type RemoveUIMessage,
|
||||
} from "@langchain/langgraph-sdk/react-ui";
|
||||
import { GITHUB_TOKEN_COOKIE } from "../constants.js";
|
||||
import {
|
||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||
GITHUB_TOKEN_COOKIE,
|
||||
} from "../constants.js";
|
||||
import { withLangGraph } from "@langchain/langgraph/zod";
|
||||
import { BaseMessage } from "@langchain/core/messages";
|
||||
|
||||
|
|
@ -310,6 +313,11 @@ export const GraphConfigurationMetadata: {
|
|||
type: "hidden",
|
||||
},
|
||||
},
|
||||
[GITHUB_INSTALLATION_TOKEN_COOKIE]: {
|
||||
x_open_swe_ui_config: {
|
||||
type: "hidden",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GraphConfiguration = z.object({
|
||||
|
|
@ -437,6 +445,16 @@ export const GraphConfiguration = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.langgraph.metadata(GraphConfigurationMetadata[GITHUB_TOKEN_COOKIE]),
|
||||
/**
|
||||
* The installation token from the GitHub app. This token allows us to take actions
|
||||
* on the repos the user has granted us access to, but on behalf of the app, not the user.
|
||||
*/
|
||||
[GITHUB_INSTALLATION_TOKEN_COOKIE]: z
|
||||
.string()
|
||||
.optional()
|
||||
.langgraph.metadata(
|
||||
GraphConfigurationMetadata[GITHUB_INSTALLATION_TOKEN_COOKIE],
|
||||
),
|
||||
});
|
||||
|
||||
export type GraphConfig = LangGraphRunnableConfig<
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue