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:
Brace Sproul 2025-06-15 12:37:31 -07:00 • committed by GitHub
parent 6c23f85eef
commit fda6529419
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 294 additions and 16 deletions

View file

@ -29,7 +29,7 @@ cp ./apps/open-swe/.env.example ./apps/open-swe/.env
cp ./apps/web/.env.example ./apps/web/.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 ```bash
# ------------------LangSmith tracing------------------ # ------------------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 for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY="" 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: And the web `.env` file should contain the following variables:
```bash ```bash
# Change to production URLs when deployed # Change both to production URLs when deployed
NEXT_PUBLIC_API_URL="http://localhost:3000/api" NEXT_PUBLIC_API_URL="http://localhost:3000/api"
LANGGRAPH_API_URL="http://localhost:2024" LANGGRAPH_API_URL="http://localhost:2024"
NEXT_PUBLIC_ASSISTANT_ID="open-swe" 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_NAME="open-swe-dev"
GITHUB_APP_ID="" GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY="" 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). 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. Give the app a name and description.

View file

@ -11,3 +11,7 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox. # Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY="" 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=""

View file

@ -1,6 +1,10 @@
import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth"; import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth";
import { verifyGithubUser, GithubUser } from "./github-auth.js"; 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"; const STUDIO_USER_ID = "langgraph-studio-user";
@ -38,18 +42,38 @@ export const auth = new Auth()
display_name: "CORS Preflight", 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 // Parse Authorization header
const accessToken = request.headers.get(GITHUB_TOKEN_COOKIE); const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
if (!accessToken) { if (!encryptedAccessToken) {
throw new HTTPException(401, { throw new HTTPException(401, {
message: "GitHub access token header missing", 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 // Validate GitHub access token
let user: GithubUser | undefined; let user: GithubUser | undefined;
try { try {
user = await verifyGithubUser(accessToken); // Ensure we decrypt the token before passing to the verification function.
user = await verifyGithubUser(
decryptGitHubToken(encryptedAccessToken, encryptionKey),
);
if (!user) { if (!user) {
throw new HTTPException(401, { throw new HTTPException(401, {
message: message:

View file

@ -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 { GraphConfig } from "@open-swe/shared/open-swe/types";
import { decryptGitHubToken } from "@open-swe/shared/crypto";
export function getGitHubTokensFromConfig(config: GraphConfig): { export function getGitHubTokensFromConfig(config: GraphConfig): {
githubAccessToken: string; githubAccessToken: string;
githubInstallationToken: string;
} { } {
if (!config.configurable) { if (!config.configurable) {
throw new Error("No configurable object found in graph config."); throw new Error("No configurable object found in graph config.");
} }
const githubAccessToken = config.configurable[GITHUB_TOKEN_COOKIE]; const encryptedGitHubToken = config.configurable[GITHUB_TOKEN_COOKIE];
if (!githubAccessToken) { const encryptedInstallationToken =
throw new Error("Missing required x-github-access-token in configuration."); 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 };
} }

View file

@ -1,3 +1,4 @@
# Change both to production URLs when deployed
NEXT_PUBLIC_API_URL="http://localhost:3000/api" NEXT_PUBLIC_API_URL="http://localhost:3000/api"
LANGGRAPH_API_URL="http://localhost:2024" LANGGRAPH_API_URL="http://localhost:2024"
NEXT_PUBLIC_ASSISTANT_ID="open-swe" 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_NAME="open-swe-dev"
GITHUB_APP_ID="" GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY="" 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=""

View file

@ -1,5 +1,50 @@
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough"; 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. // 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. // 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", apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
runtime: "edge", // default runtime: "edge", // default
disableWarningLog: true, 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 { return {
[GITHUB_TOKEN_COOKIE]: [GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey),
req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "", [GITHUB_INSTALLATION_TOKEN_COOKIE]:
await getGitHubInstallationTokenOrThrow(req, encryptionKey),
}; };
}, },
}); });

View file

@ -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. // 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_TOKEN_COOKIE = "x-github-access-token";
export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token";

View 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"}`,
);
}
}

View file

@ -13,7 +13,10 @@ import {
type UIMessage, type UIMessage,
type RemoveUIMessage, type RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui"; } 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 { withLangGraph } from "@langchain/langgraph/zod";
import { BaseMessage } from "@langchain/core/messages"; import { BaseMessage } from "@langchain/core/messages";
@ -310,6 +313,11 @@ export const GraphConfigurationMetadata: {
type: "hidden", type: "hidden",
}, },
}, },
[GITHUB_INSTALLATION_TOKEN_COOKIE]: {
x_open_swe_ui_config: {
type: "hidden",
},
},
}; };
export const GraphConfiguration = z.object({ export const GraphConfiguration = z.object({
@ -437,6 +445,16 @@ export const GraphConfiguration = z.object({
.string() .string()
.optional() .optional()
.langgraph.metadata(GraphConfigurationMetadata[GITHUB_TOKEN_COOKIE]), .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< export type GraphConfig = LangGraphRunnableConfig<