From a802a5bffe60b54e9247b9d68fb6ee6d00bbe47f Mon Sep 17 00:00:00 2001 From: Andy L WSL Date: Sun, 1 Mar 2026 19:03:39 +0000 Subject: [PATCH] added standard auth section --- .env.template | 1 + .gitignore | 1 + docs/AddingCloudOperations.md | 122 ++ docs/GoogleCloudSetup.md | 95 +- docs/LocalTesting.md | 5 +- docs/Troubleshooting.md | 36 + src/core/lib/GoogleCloud.mjs | 102 +- .../operations/AuthenticateGoogleCloud.mjs | 167 ++ src/core/operations/GCloudListBucket.mjs | 9 +- src/core/operations/GCloudReadFile.mjs | 10 +- src/core/operations/GCloudSpeechToText.mjs | 34 +- src/core/operations/GoogleTranslate.mjs | 9 +- src/web/html/index.html | 1783 +++++++++-------- src/web/waiters/InputWaiter.mjs | 40 +- src/web/waiters/WorkerWaiter.mjs | 41 +- tests/browser/03_cloud_ops.js | 209 +- 16 files changed, 1640 insertions(+), 1024 deletions(-) create mode 100644 docs/AddingCloudOperations.md create mode 100644 docs/Troubleshooting.md create mode 100644 src/core/operations/AuthenticateGoogleCloud.mjs diff --git a/.env.template b/.env.template index ba1cbdc2..c7df2ea8 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,4 @@ # CyberChef Cloud Operations Tests # Copy this file to `.env` and fill in your valid GCP API Key if you want to run the Nightwatch browser tests against live Cloud APIs. CYBERCHEF_GCP_TEST_KEY="YOUR_API_KEY_HERE" +CLIENT_ID="YOUR_CLIENT_ID_HERE" diff --git a/.gitignore b/.gitignore index a161bb1b..f049c572 100755 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ src/node/index.mjs tests/browser/output/* .node-version .env +.antigravity diff --git a/docs/AddingCloudOperations.md b/docs/AddingCloudOperations.md new file mode 100644 index 00000000..150629bf --- /dev/null +++ b/docs/AddingCloudOperations.md @@ -0,0 +1,122 @@ +# Guide: Adding New Google Cloud Operations to CyberChef + +With the introduction of the Google Identity Services (GIS) Web Application PKCE flow and strict Content Security Policies (CSP), adding new Google Cloud operations requires a few extra steps compared to standard CyberChef operations. + +This guide outlines the exact process for creating, securing, and testing a new Google Cloud integration. + +## 1. Create the Operation File +Create your new operation in `src/core/operations/` (e.g., `GCloudVisionAnalyze.mjs`). +Include the standard CyberChef operation scaffolding and ensure you import the authentication utilities from `GoogleCloud.mjs`: + +```javascript +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { applyGCPAuth } from "../lib/GoogleCloud.mjs"; + +class GCloudVisionAnalyze extends Operation { + constructor() { + super(); + this.name = "GCloud Vision Analyze"; + this.module = "Cloud"; + this.description = "Analyzes an image using Google Cloud Vision."; + this.infoURL = "https://cloud.google.com/vision/docs"; + this.inputType = "ArrayBuffer"; + this.outputType = "JSON"; + this.args = [ + /* Your args */ + ]; + } +``` + +## 2. Leverage `applyGCPAuth` +**Do not** add authentication fields (like API keys or tokens) directly into your new operation's `args`. +Instead, instruct the user (via the `description`) to place the `Authenticate Google Cloud` operation at the top of their recipe. + +Inside your `run` method, use `applyGCPAuth` to seamlessly inject the credentials. This handles API Keys, Personal Access Tokens (PATs), and the GIS OAuth Tokens interchangeably: + +```javascript + async run(input, args) { + let url = "https://vision.googleapis.com/v1/images:annotate"; + let headers = { "Content-Type": "application/json" }; + + // 1. Inject Auth into URL or Headers + try { + [url, headers] = applyGCPAuth(url, headers); + } catch (e) { + throw new OperationError(e.message); + } + + // 2. Perform Fetch + const response = await fetch(url, { + method: "POST", + headers: headers, + body: JSON.stringify({ /* payload */ }) + }); + + // 3. Handle response... + } +``` + +## 3. Update Content Security Policy (CSP) +CyberChef utilizes strict CSP headers to prevent XSS attacks from exfiltrating the OAuth tokens stored in `sessionStorage`. +If your new operation contacts a new Google API endpoint, **the request will be blocked by the browser** unless you whitelist it. + +1. Open `src/web/html/index.html`. +2. Locate the `` tag in the ``. +3. Find the `connect-src` directive. +4. Append your new API endpoint (e.g., `https://vision.googleapis.com`). + +Example: +```html + +``` + +## 4. Testing End-to-End (Nightwatch) + +Because the PKCE OAuth flow relies on a popup protected by anti-bot measures (reCAPTCHA) from Google Identity Services, **it cannot be fully automated** with headless browsers like Nightwatch. + +For E2E tests, you **must use** either an API Key or a PAT (`CYBERCHEF_GCP_TEST_KEY` or `CYBERCHEF_GCP_TEST_TOKEN`). + +1. Open `tests/browser/03_cloud_ops.js`. +2. Create your test suite. +3. Use the PAT or API Key fallback logic present in other tests to inject credentials into your dummy recipe: + +```javascript + "GCloud Vision Analyze: Translates image to text": function (browser) { + let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN; + + // Fallback to gcloud SDK if CYBERCHEF_GCP_TEST_TOKEN isn't set manually + if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") { + try { + testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim(); + } catch (e) { + console.log("No valid token found. Skipping live Vision API test."); + return; + } + } + + browserUtils.loadRecipeConfig(browser, [ + { + op: "Authenticate Google Cloud", + args: [ + "Personal Access Token (PAT)", + { option: "UTF8", string: testToken }, + "cyberchefcloud", + true + ] + }, + { + op: "GCloud Vision Analyze", + args: [] + } + ], "Dummy Input data..."); + + browser.waitForElementNotVisible("#snackbar-container", 6000); + browserUtils.bake(browser); + browser.pause(5000); + + // Assert output + } +``` + +Run tests with `npm run test:browser` or `npx nightwatch tests/browser/03_cloud_ops.js` (while `npm run start` is running locally). diff --git a/docs/GoogleCloudSetup.md b/docs/GoogleCloudSetup.md index cb291136..fc8ded46 100644 --- a/docs/GoogleCloudSetup.md +++ b/docs/GoogleCloudSetup.md @@ -1,37 +1,58 @@ # Google Cloud Setup Guide for CyberChef -To use Google Cloud capabilities (like Google Translate) within CyberChef, you need to configure a Google Cloud Project and obtain an authentication string (either an API Key or an OAuth Token). +To use Google Cloud capabilities (like Google Translate, Speech-to-Text, or Cloud Storage) within CyberChef, you need to configure a Google Cloud Project and obtain an authentication string. We strongly recommend using **OAuth 2.0 (Web Application: PKCE)** as it is the most secure method for browser-based applications like CyberChef. -## Method 1: API Key (Recommended for simplicity) +By setting up a personal Cloud Project, your CyberChef instance communicates directly with your Google Cloud without a middleman. -1. **Create a Project:** - - Go to the [Google Cloud Console](https://console.cloud.google.com/). - - Click on the project dropdown at the top and select **New Project**. - - Name your project (e.g., `cyberchefcloud`) and click **Create**. +## Method 1: OAuth 2.0 (Web Application: PKCE) - Highly Recommended -2. **Enable the API:** - - In the Cloud Console search bar, type **"Cloud Translation API"** and select it. - - Click **Enable**. - - *(Note: You will need to have billing enabled on your Google Cloud account for the Translation API, even for the free tier).* +This method uses your Google account and Google Identity Services to grant CyberChef temporary, secure access to your Google Cloud Project. Because CyberChef is a client-side architecture web app without a backend server, we use the Authorization Code Flow with Proof Key for Code Exchange (PKCE). -3. **Create the API Key:** - - Navigate to **APIs & Services > Credentials** in the left sidebar. - - Click **+ CREATE CREDENTIALS** at the top and select **API Key**. - - Your API Key will be generated. Copy this key; you will need it for the CyberChef "GCP Auth String" input. +### Step 1: Create a Project +1. Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. Click on the project dropdown at the top and select **New Project**. +3. Name your project (e.g., `cyberchef-personal`) and click **Create**. -4. **Secure the API Key (CRITICAL):** - - Since CyberChef runs entirely in your browser, your API Key will be visible to anyone you share your CyberChef recipe with or who inspects the network traffic. you **MUST** restrict it. - - Click on the newly created API Key to edit its settings. - - Under **Application restrictions**, select **Websites**. - - Under **Website restrictions**, click **ADD**. - - Enter the URLs where your CyberChef instance is hosted (e.g., `https://gchq.github.io/CyberChef/*` or `http://localhost:8080/*` for local testing). - - Under **API restrictions**, select **Restrict key**. - - Check the box for **Cloud Translation API**. - - Click **SAVE**. +### Step 2: Enable Required APIs +You must enable the APIs for any operation you intend to use. +1. In the Cloud Console search bar, type the name of the API and select it, then click **Enable**. + - For **Google Translate**: Enable "Cloud Translation API" (requires billing enabled). + - For **Speech-to-Text**: Enable "Cloud Speech-to-Text API". + - For **Read/List Bucket**: Enable "Cloud Storage JSON API" (usually enabled by default). -## Method 2: Temporary OAuth Token (Recommended for Security) +### Step 3: Configure the OAuth Consent Screen +1. Navigate to **APIs & Services > OAuth consent screen** in the left sidebar. +2. Select **External** user type and click **Create**. +3. Fill in the required fields (App Name, User Support Email, Developer Contact Info). You can use your own email for all of these. Click **Save and Continue**. +4. Skip the **Scopes** section by clicking **Save and Continue**. +5. **CRITICAL STEP: Configure Test Users**. + - Navigate to **APIs & Services > OAuth consent screen** > **Audience** > **Test Users** > **Add Users** + - Type your own Google email address here (the one you use to login to the Cloud Console). + - *(Since we will leave the app in "Testing" state forever, only users listed here can authenticate).* + - **WARNING**: Do **NOT** click "Publish App". Leave the publishing status as "Testing". If you publish it, Google will require your app to undergo a verification process. -If you have the Google Cloud SDK (`gcloud`) installed locally and you are authorized in your project, you can generate a short-lived token to use instead of an API Key. This is much more secure because the token expires automatically. +### Step 4: Create OAuth Client ID +1. Navigate to **APIs & Services > Credentials** in the left sidebar. +2. Click **+ CREATE CREDENTIALS** at the top and select **OAuth client ID**. +3. For **Application type**, select **Web application**. +4. Name it something like "CyberChef PKCE Client". +5. Under **Authorized JavaScript origins**, click **ADD URI**. + - For local testing, add: `http://localhost:8080` + - For production, add your deployed CyberChef URL (e.g., `https://gchq.github.io`). +6. Click **Create**. +7. A box will appear with your **Client ID**. Copy this string. *(Note: You do not need the Client Secret).* + +### Step 5: Authenticate in CyberChef +1. In CyberChef, add the **Authenticate Google Cloud** operation to the TOP of your recipe. +2. Set "Auth Type" to **OAuth 2.0 (Web Application: PKCE)**. +3. Paste your Client ID into the "Credentials" box. +4. Click **Bake**. CyberChef will popup a secure Google Login window. Your session token is now securely stored in your browser's Session Storage for the duration of the tab being open. + +--- + +## Method 2: Temporary Personal Access Token (PAT) + +If you have the Google Cloud SDK (`gcloud`) installed locally and you are authorized in your project, you can generate a short-lived token to use. This is secure because the token expires automatically. 1. Ensure you are logged into your `gcloud` CLI: ```bash @@ -41,4 +62,26 @@ If you have the Google Cloud SDK (`gcloud`) installed locally and you are author ```bash gcloud auth print-access-token ``` -3. Copy the output token and paste it into the CyberChef "GCP Auth String" input, making sure to change the "Auth Type" dropdown to **OAuth Token**. +3. In CyberChef's **Authenticate Google Cloud** operation, set "Auth Type" to **Personal Access Token (PAT)** and paste the token output into the Credentials box. + +--- + +## Method 3: API Key (For Simple APIs only) + +*Warning: API Keys are not recommended for operations that access private data like Cloud Storage. They are best suited for simple APIs like Translation.* + +1. **Create the API Key:** + - Navigate to **APIs & Services > Credentials** in the left sidebar. + - Click **+ CREATE CREDENTIALS** at the top and select **API Key**. + - Your API Key will be generated. Copy this key. + +2. **Secure the API Key (CRITICAL):** + - Since CyberChef runs entirely in your browser, your API Key will be visible to anyone you share your CyberChef recipe with or who inspects the network traffic. you **MUST** restrict it. + - Click on the newly created API Key to edit its settings. + - Under **Application restrictions**, select **Websites**. + - Under **Website restrictions**, click **ADD**. + - Enter the URLs where your CyberChef instance is hosted (e.g., `https://gchq.github.io/CyberChef/*` or `http://localhost:8080/*` for local testing). + - Under **API restrictions**, select **Restrict key** and check the relevant API (e.g., **Cloud Translation API**). + - Click **SAVE**. + +3. In CyberChef's **Authenticate Google Cloud** operation, set "Auth Type" to **API Key** and paste the key into the Credentials box. diff --git a/docs/LocalTesting.md b/docs/LocalTesting.md index c4e94878..83de3b7f 100644 --- a/docs/LocalTesting.md +++ b/docs/LocalTesting.md @@ -58,7 +58,9 @@ npm run test CyberChef is equipped with Nightwatch for end-to-end testing of features running in a real Chromium browser. This is extremely useful for Cloud Operations that cannot be easily tested within the headless NodeJS environment. -### Setting up API Keys +**Important Note on Authentication:** Google Identity Services (GIS), the library used for the secure **OAuth 2.0 (Web Application: PKCE)** flow, contains strict anti-automation reCAPTCHA logic. Because of this, it is nearly impossible to automate the Google Login popup using standard E2E frameworks like Nightwatch. Therefore, all automated Nightwatch tests utilize either **API Keys** or **Personal Access Tokens (PATs)** to authenticate. Manual testing is still required to verify the PKCE Popup flow works as expected for human users. + +### Setting up Credentials To prevent live API keys from being leaked in CI/CD, the Cloud API browser tests rely on local environment variables: 1. Copy the `.env.template` file to `.env`: @@ -66,6 +68,7 @@ To prevent live API keys from being leaked in CI/CD, the Cloud API browser tests cp .env.template .env ``` 2. Open `.env` and replace `YOUR_API_KEY_HERE` with your actual Google Cloud API key for the `CYBERCHEF_GCP_TEST_KEY` variable. Ensure the API Key restrictions at console.cloud.google.com are set up to accept requests from `http://localhost:8080/*`. +3. If tests require a PAT, you must update `CYBERCHEF_GCP_TEST_TOKEN` with a fresh token obtained via `gcloud auth print-access-token` before running the tests. ### Running Nightwatch Tests diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md new file mode 100644 index 00000000..7c3da17b --- /dev/null +++ b/docs/Troubleshooting.md @@ -0,0 +1,36 @@ +# Google Cloud Troubleshooting Guide + +## Error: "401 Anonymous caller" or "403 Permission 'storage.objects.list' denied" + +If you successfully authenticated with the **Authenticate Google Cloud** operation but received a `401` or `403` error when trying to run an operation like **GCloud List Bucket** or **GCloud Read File**, the issue is **Identity and Access Management (IAM)**. + +Even though your token is valid (Authentication), your Google Account does not have the necessary permissions (Authorization) to perform that specific action on that specific resource. + +### How to Fix IAM Permissions + +1. **Verify Your Google Login Account** + Make sure the email address you selected in the Google Login popup is the *exact same* email address that holds permissions in your Google Cloud Project. + +2. **Grant Storage Roles** + To read from Cloud Storage, your Google Account must be granted a role that contains the required permissions (like `storage.objects.list` and `storage.objects.get`). + + - Go to the [Google Cloud Console](https://console.cloud.google.com). + - Navigate to **IAM & Admin > IAM**. + - Find your email address in the list of principals. + - Click the pencil icon (Edit principal) next to your name. + - Click **Add Another Role**. + - To fully utilize the CyberChef Cloud Storage operations, add the **Storage Object Viewer** role (or **Storage Object Admin** if you plan to write files later). + - Click **Save**. + + *Note: IAM changes can take anywhere from 60 seconds to a few minutes to propagate across Google's infrastructure.* + +3. **Check Bucket-Level Permissions** + If the bucket belongs to a different project than your OAuth Client ID, or has "fine-grained" access control instead of "Uniform" access control, you may need to grant yourself permission directly on the bucket itself. + - Navigate to **Cloud Storage > Buckets**. + - Click on the bucket you are trying to read. + - Go to the **Permissions** tab. + - Click **Grant Access**. + - Add your Google email address and assign the **Storage Object Viewer** role. + +4. **Retry the Operation** + After waiting two minutes for IAM propagation, refresh CyberChef (this forces a new session and drops the old token). Re-authenticate with **Authenticate Google Cloud**, then bake the recipe again. diff --git a/src/core/lib/GoogleCloud.mjs b/src/core/lib/GoogleCloud.mjs index a8ff268c..04284c54 100644 --- a/src/core/lib/GoogleCloud.mjs +++ b/src/core/lib/GoogleCloud.mjs @@ -6,17 +6,38 @@ import OperationError from "../errors/OperationError.mjs"; +/** + * Global store for GCP credentials in this web worker session. + */ +globalThis.__gcpAuthStore = globalThis.__gcpAuthStore || null; + +/** + * Retrieves the currently active GCP credentials. + * @returns {Object|null} { authType, authString, quotaProject } + */ +export function get_gcp_credentials() { + if (globalThis.__gcpAuthStore) { + return globalThis.__gcpAuthStore; + } + return null; +} + +/** + * Sets the active GCP credentials for the web worker session. + * @param {Object} credObj { authType, authString, quotaProject } + */ +export function set_gcp_credentials(credObj) { + globalThis.__gcpAuthStore = credObj; +} + /** * Lists objects in a GCS bucket under a given prefix. * * @param {string} bucket - The GCS bucket name (without gs://). * @param {string} prefix - The folder prefix to filter by (e.g. "audio/"). - * @param {string} authType - "API Key" or "OAuth Token". - * @param {Object|string} authStringObj - The auth credential. - * @param {string} quotaProject - Optional quota project for ADC OAuth tokens. * @returns {Promise} Array of GCS object metadata { name, gs_uri, size, contentType }. */ -export async function listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject) { +export async function listGCSBucket(bucket, prefix) { let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o`; const params = new URLSearchParams(); if (prefix) params.set("prefix", prefix); @@ -25,7 +46,7 @@ export async function listGCSBucket(bucket, prefix, authType, authStringObj, quo if (paramStr) url += `?${paramStr}`; const headers = new Headers(); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); let data; @@ -54,12 +75,9 @@ export async function listGCSBucket(bucket, prefix, authType, authStringObj, quo * Downloads a file from GCS and returns its raw bytes. * * @param {string} gcsUri - Full gs:// URI of the file. - * @param {string} authType - "API Key" or "OAuth Token". - * @param {Object|string} authStringObj - The auth credential. - * @param {string} quotaProject - Optional quota project. * @returns {Promise} Raw file bytes. */ -export async function readGCSFile(gcsUri, authType, authStringObj, quotaProject) { +export async function readGCSFile(gcsUri) { const match = gcsUri.match(/^gs:\/\/([^/]+)\/(.+)$/); if (!match) throw new OperationError(`GCloud Read File: Invalid GCS URI: ${gcsUri}`); const [, bucket, object] = match; @@ -67,7 +85,7 @@ export async function readGCSFile(gcsUri, authType, authStringObj, quotaProject) let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodedObject}?alt=media`; const headers = new Headers(); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); if (!response.ok) { @@ -84,18 +102,15 @@ export async function readGCSFile(gcsUri, authType, authStringObj, quotaProject) * @param {string} bucket - Destination bucket name (without gs://). * @param {string} objectPath - Destination object path within the bucket. * @param {string} content - Text content to write. - * @param {string} authType - "API Key" or "OAuth Token". - * @param {Object|string} authStringObj - The auth credential. - * @param {string} quotaProject - Optional quota project. * @returns {Promise} The gs:// URI of the written file. */ -export async function writeGCSFile(bucket, objectPath, content, authType, authStringObj, quotaProject) { +export async function writeGCSFile(bucket, objectPath, content) { const encodedObject = encodeURIComponent(objectPath).replace(/%2F/g, "%2F"); let url = `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o?uploadType=media&name=${encodedObject}`; const headers = new Headers(); headers.set("Content-Type", "text/plain; charset=utf-8"); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const response = await fetch(authed.url, { method: "POST", @@ -117,15 +132,12 @@ export async function writeGCSFile(bucket, objectPath, content, authType, authSt * * @param {string} operationName - The operation ID (numeric string from the API response). * @param {string} pollUrl - The base polling URL (e.g. https://speech.googleapis.com/v1/operations/). - * @param {string} authType - "API Key" or "OAuth Token". - * @param {Object|string} authStringObj - The auth credential. - * @param {string} quotaProject - Optional quota project. * @param {number} maxMs - Maximum wait time in milliseconds (default 30 minutes). * @param {number} intervalMs - Poll interval in milliseconds (default 10 seconds). * @param {Function} onProgress - Optional callback(elapsedSeconds) called on each poll tick. * @returns {Promise} The completed operation response object. */ -export async function pollLongRunningOperation(operationName, pollUrl, authType, authStringObj, quotaProject, maxMs = 30 * 60 * 1000, intervalMs = 10000, onProgress = null) { +export async function pollLongRunningOperation(operationName, pollUrl, maxMs = 30 * 60 * 1000, intervalMs = 10000, onProgress = null) { const startTime = Date.now(); const url = `${pollUrl}${operationName}`; @@ -136,7 +148,7 @@ export async function pollLongRunningOperation(operationName, pollUrl, authType, } const headers = new Headers(); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); let data; @@ -158,52 +170,26 @@ export async function pollLongRunningOperation(operationName, pollUrl, authType, } /** - * Common arguments for Google Cloud Platform operations - * - * Spread these into the `args` array of any new GCP operation. - */ -export const GCP_AUTH_ARGS = [ - { - "name": "Auth Type", - "type": "option", - "value": ["API Key", "OAuth Token"] - }, - { - "name": "GCP Auth String", - "type": "toggleString", - "value": "", - "toggleValues": ["UTF8", "Latin1", "Base64", "Hex"] - }, - { - "name": "Quota Project (ADC only)", - "type": "string", - "value": "" - } -]; - -/** - * Validates and applies GCP authentication to a URL and Headers object. + * Validates and applies GCP authentication to a URL and Headers object + * using the globally cached credentials from AuthenticateGoogleCloud. * * @param {string} url - The base URL of the API endpoint. * @param {Headers} headers - The Headers object for the request. - * @param {string} authType - "API Key" or "OAuth Token". - * @param {Object|string} authStringObj - The authentication string (can be a toggleString object). - * @param {string} quotaProject - Optional quota project for ADC OAuth tokens. * @returns {Object} An object containing the modified { url, headers } */ -export function applyGCPAuth(url, headers, authType, authStringObj, quotaProject) { - const authString = typeof authStringObj === "string" ? authStringObj : (authStringObj.string || ""); +export function applyGCPAuth(url, headers) { + const creds = get_gcp_credentials(); - if (!authString) { - throw new OperationError("Error: Please provide a valid GCP Auth String (API Key or OAuth Token)."); + if (!creds || !creds.authString) { + throw new OperationError("No Google Cloud credentials found. Please add the 'Authenticate Google Cloud' operation before this one."); } - if (authType === "API Key") { - url += `${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(authString)}`; - } else if (authType === "OAuth Token") { - headers.set("Authorization", `Bearer ${authString}`); - if (quotaProject) { - headers.set("x-goog-user-project", quotaProject); + if (creds.authType === "API Key") { + url += `${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(creds.authString)}`; + } else if (creds.authType === "OAuth 2.0 (Web Application: PKCE)" || creds.authType === "Personal Access Token (PAT)") { + headers.set("Authorization", `Bearer ${creds.authString}`); + if (creds.quotaProject) { + headers.set("x-goog-user-project", creds.quotaProject); } } diff --git a/src/core/operations/AuthenticateGoogleCloud.mjs b/src/core/operations/AuthenticateGoogleCloud.mjs new file mode 100644 index 00000000..83924bc8 --- /dev/null +++ b/src/core/operations/AuthenticateGoogleCloud.mjs @@ -0,0 +1,167 @@ +/** + * @author CyberChefCloud + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { set_gcp_credentials, get_gcp_credentials } from "../lib/GoogleCloud.mjs"; +import { isWorkerEnvironment } from "../Utils.mjs"; + +/** + * Authenticate Google Cloud operation + */ +class AuthenticateGoogleCloud extends Operation { + + /** + * AuthenticateGoogleCloud constructor + */ + constructor() { + super(); + + this.name = "Authenticate Google Cloud"; + this.module = "Cloud"; + this.description = [ + "Authenticates with Google Cloud Platform.", + "

", + "This operation should be placed at the top of your recipe. It securely manages credentials for downstream Google Cloud operations (e.g. List Bucket, Read File, Speech-to-Text).", + "

", + "You can authenticate using:", + "
    ", + "
  • OAuth 2.0 (Web Application: PKCE): The recommended, secure method. Provide your Web Application Client ID. CyberChef will popup a secure Google login window. The token is stored per-session and cleared when you close the tab.
  • ", + "
  • Personal Access Token (PAT): Provide a short-lived bearer token (e.g. from gcloud auth print-access-token).
  • ", + "
  • API Key: Provide a Google Cloud API key. (Less secure, ensure it is restricted).
  • ", + "
" + ].join("\n"); + this.infoURL = "https://cloud.google.com/docs/authentication"; + this.inputType = "string"; + this.outputType = "string"; + this.manualBake = true; // AutoBake must be disabled to prevent spamming the OAuth API + this.args = [ + { + "name": "Auth Type", + "type": "option", + "value": ["OAuth 2.0 (Web Application: PKCE)", "Personal Access Token (PAT)", "API Key"] + }, + { + "name": "Credentials (Client ID, PAT, or API Key)", + "type": "toggleString", + "value": "", + "toggleValues": ["UTF8", "Latin1", "Base64", "Hex"] + }, + { + "name": "Quota Project (OAuth only)", + "type": "string", + "value": "" + }, + { + "name": "Output Logs", + "type": "boolean", + "value": true + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + async run(input, args) { + const [authType, credObj, quotaProject, outputLogs] = args; + const credString = typeof credObj === "string" ? credObj : (credObj.string || ""); + + if (!credString) { + throw new OperationError("Please provide Google Cloud credentials (Client ID, PAT, or API Key)."); + } + + let logs = ""; + const log = (msg) => { + if (outputLogs) logs += msg + "\n"; + // Also send to the UI status bar if in worker + if (isWorkerEnvironment()) self.sendStatusMessage(msg); + }; + + log("Starting Google Cloud Authentication..."); + + // If not using the Web App PKCE Flow, just cache the PAT/API Key and return. + if (authType !== "OAuth 2.0 (Web Application: PKCE)") { + set_gcp_credentials({ + authType: authType, + authString: credString, + quotaProject: quotaProject + }); + log(`Successfully configured ${authType}.`); + return logs ? (logs + "\n" + input) : input; + } + + // --- OAuth 2.0 Web Application (PKCE) Flow --- + + // 1. Check if we already have a valid token for this Client ID in the session + const existingCreds = get_gcp_credentials(); + if (existingCreds && existingCreds.authType === "OAuth 2.0 (Web Application: PKCE)" && existingCreds.clientId === credString) { + if (existingCreds.expiresAt > Date.now()) { + log("Reusing valid existing OAuth session token."); + return logs ? (logs + "\n" + input) : input; + } + log("Existing OAuth token expired. A new authorization is required."); + } + + // 2. Pause the Web Worker and ask the Main UI to pop the GIS login window + log("Requesting Google Login Popup (Check your browser windows)..."); + + // We use a Promise to halt the `run` method until the UI sends back the token + const tokenData = await new Promise((resolve, reject) => { + + // Temporary message listener to catch the response from the UI + const messageHandler = function (e) { + const r = e.data; + if (r.action === "gcpAuthResponse") { + self.removeEventListener("message", messageHandler); + if (r.data.error) { + reject(new OperationError(`Google OAuth Error: ${r.data.error}`)); + } else if (r.data.token) { + resolve(r.data); + } else { + reject(new OperationError("Google OAuth Error: UI returned unexpected empty token payload.")); + } + } + }; + self.addEventListener("message", messageHandler); + + // Trigger the UI + // Assuming this operation is executed within a ChefWorker, we bubble up `gcpAuthRequest` + // and we must include `inputNum` so WorkerWaiter knows which worker to send the response back to. + if (!isWorkerEnvironment()) { + reject(new OperationError("OAuth PKCE Flow can only run in a Web Worker environment. For manual node testing, use PAT or API Key auth types.")); + return; + } + + self.postMessage({ + action: "gcpAuthRequest", + data: { + clientId: credString, + inputNum: self.inputNum || 0 + } + }); + }); + + log("Authorization successful!"); + log(`Access token retrieved. Expires in ${tokenData.expires_in} seconds.`); + + // 3. Cache the credentials for downstream operations + set_gcp_credentials({ + authType: "OAuth 2.0 (Web Application: PKCE)", + authString: tokenData.token, + quotaProject: quotaProject, + clientId: credString, + expiresAt: Date.now() + (tokenData.expires_in * 1000) + }); + + return logs ? (logs + "\n" + input) : input; + } + +} + +export default AuthenticateGoogleCloud; diff --git a/src/core/operations/GCloudListBucket.mjs b/src/core/operations/GCloudListBucket.mjs index 7c039419..5c22577e 100644 --- a/src/core/operations/GCloudListBucket.mjs +++ b/src/core/operations/GCloudListBucket.mjs @@ -6,7 +6,7 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -import { GCP_AUTH_ARGS, applyGCPAuth, listGCSBucket } from "../lib/GoogleCloud.mjs"; +import { listGCSBucket } from "../lib/GoogleCloud.mjs"; /** * GCloud List Bucket operation @@ -44,8 +44,7 @@ class GCloudListBucket extends Operation { "name": "Output Format", "type": "option", "value": ["GCS URIs (one per line)", "Filenames only", "JSON"] - }, - ...GCP_AUTH_ARGS + } ]; } @@ -55,7 +54,7 @@ class GCloudListBucket extends Operation { * @returns {string} */ async run(input, args) { - const [prefix, outputFormat, authType, authStringObj, quotaProject] = args; + const [prefix, outputFormat] = args; if (!input || !input.trim()) throw new OperationError("Please provide a GCS bucket name."); @@ -63,7 +62,7 @@ class GCloudListBucket extends Operation { let bucket = input.trim().replace(/^gs:\/\//, "").split("/")[0]; try { - const items = await listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject); + const items = await listGCSBucket(bucket, prefix); if (items.length === 0) { return `No objects found in gs://${bucket}/${prefix || ""}`; diff --git a/src/core/operations/GCloudReadFile.mjs b/src/core/operations/GCloudReadFile.mjs index 4f5058c7..61ad2a0d 100644 --- a/src/core/operations/GCloudReadFile.mjs +++ b/src/core/operations/GCloudReadFile.mjs @@ -6,7 +6,7 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -import { GCP_AUTH_ARGS, readGCSFile } from "../lib/GoogleCloud.mjs"; +import { readGCSFile } from "../lib/GoogleCloud.mjs"; /** * GCloud Read File operation @@ -35,9 +35,7 @@ class GCloudReadFile extends Operation { this.inputType = "string"; this.outputType = "ArrayBuffer"; this.manualBake = true; - this.args = [ - ...GCP_AUTH_ARGS - ]; + this.args = []; } /** @@ -46,15 +44,13 @@ class GCloudReadFile extends Operation { * @returns {ArrayBuffer} */ async run(input, args) { - const [authType, authStringObj, quotaProject] = args; - const uri = input.trim(); if (!uri.startsWith("gs://")) { throw new OperationError("Input must be a GCS URI starting with gs://"); } try { - return await readGCSFile(uri, authType, authStringObj, quotaProject); + return await readGCSFile(uri); } catch (e) { if (e.name === "OperationError") throw e; throw new OperationError(e.message || e.toString()); diff --git a/src/core/operations/GCloudSpeechToText.mjs b/src/core/operations/GCloudSpeechToText.mjs index 666a8cc6..a0b17f8d 100644 --- a/src/core/operations/GCloudSpeechToText.mjs +++ b/src/core/operations/GCloudSpeechToText.mjs @@ -6,7 +6,7 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -import { GCP_AUTH_ARGS, applyGCPAuth, pollLongRunningOperation, writeGCSFile } from "../lib/GoogleCloud.mjs"; +import { applyGCPAuth, pollLongRunningOperation, writeGCSFile } from "../lib/GoogleCloud.mjs"; /** * GCloud Speech to Text operation @@ -73,8 +73,7 @@ class GCloudSpeechToText extends Operation { "name": "Max Poll Minutes", "type": "number", "value": 30 - }, - ...GCP_AUTH_ARGS + } ]; } @@ -85,16 +84,12 @@ class GCloudSpeechToText extends Operation { */ async run(input, args) { const [ - inputMode, languageCode, model, outputDest, outputBucket, maxPollMinutes, - authType, authStringObj, quotaProject + inputMode, languageCode, model, outputDest, outputBucket, maxPollMinutes ] = args; const uri = input.trim(); if (!uri) throw new OperationError("Please provide a GCS URI or Base64 audio input."); - const authString = typeof authStringObj === "string" ? authStringObj : (authStringObj.string || ""); - if (!authString) throw new OperationError("Please provide a valid GCP Auth String (API Key or OAuth Token)."); - const maxMs = maxPollMinutes * 60 * 1000; let transcript; @@ -102,10 +97,10 @@ class GCloudSpeechToText extends Operation { if (!uri.startsWith("gs://")) { throw new OperationError("Input Mode is set to GCS URI but input does not start with gs://"); } - transcript = await this._transcribeGcsUri(uri, languageCode, model, authType, authStringObj, quotaProject, maxMs); + transcript = await this._transcribeGcsUri(uri, languageCode, model, maxMs); } else { // Raw audio bytes (Base64) - transcript = await this._transcribeRawAudio(uri, languageCode, model, authType, authStringObj, quotaProject); + transcript = await this._transcribeRawAudio(uri, languageCode, model); } if (outputDest === "Write to GCS") { @@ -115,7 +110,7 @@ class GCloudSpeechToText extends Operation { : "raw_audio"; const objectPath = `output/audio/${sourceFilename}/speech-to-text/text.txt`; - const destUri = await writeGCSFile(outputBucket, objectPath, transcript, authType, authStringObj, quotaProject); + const destUri = await writeGCSFile(outputBucket, objectPath, transcript); return destUri; } @@ -128,17 +123,14 @@ class GCloudSpeechToText extends Operation { * @param {string} gcsUri * @param {string} languageCode * @param {string} model - * @param {string} authType - * @param {Object|string} authStringObj - * @param {string} quotaProject * @param {number} maxMs * @returns {Promise} */ - async _transcribeGcsUri(gcsUri, languageCode, model, authType, authStringObj, quotaProject, maxMs) { + async _transcribeGcsUri(gcsUri, languageCode, model, maxMs) { let url = "https://speech.googleapis.com/v1/speech:longrunningrecognize"; const headers = new Headers(); headers.set("Content-Type", "application/json; charset=utf-8"); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const body = JSON.stringify({ config: { @@ -174,9 +166,6 @@ class GCloudSpeechToText extends Operation { const completed = await pollLongRunningOperation( operationName, POLL_URL, - authType, - authStringObj, - quotaProject, maxMs, 10000, (elapsedSec) => { @@ -195,16 +184,13 @@ class GCloudSpeechToText extends Operation { * @param {string} base64Audio * @param {string} languageCode * @param {string} model - * @param {string} authType - * @param {Object|string} authStringObj - * @param {string} quotaProject * @returns {Promise} */ - async _transcribeRawAudio(base64Audio, languageCode, model, authType, authStringObj, quotaProject) { + async _transcribeRawAudio(base64Audio, languageCode, model) { let url = "https://speech.googleapis.com/v1/speech:recognize"; const headers = new Headers(); headers.set("Content-Type", "application/json; charset=utf-8"); - const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const authed = applyGCPAuth(url, headers); const body = JSON.stringify({ config: { diff --git a/src/core/operations/GoogleTranslate.mjs b/src/core/operations/GoogleTranslate.mjs index c18b4b51..8e4cbe8a 100644 --- a/src/core/operations/GoogleTranslate.mjs +++ b/src/core/operations/GoogleTranslate.mjs @@ -6,7 +6,7 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -import { GCP_AUTH_ARGS, applyGCPAuth } from "../lib/GoogleCloud.mjs"; +import { applyGCPAuth } from "../lib/GoogleCloud.mjs"; /** * Google Translate operation @@ -41,8 +41,7 @@ class GoogleTranslate extends Operation { "name": "Target Language (ISO-639-1)", "type": "string", "value": "es" - }, - ...GCP_AUTH_ARGS + } ]; } @@ -52,7 +51,7 @@ class GoogleTranslate extends Operation { * @returns {string} */ async run(input, args) { - const [sourceLanguage, targetLanguage, authType, authStringObj, quotaProject] = args; + const [sourceLanguage, targetLanguage] = args; if (input.length === 0) return ""; @@ -60,7 +59,7 @@ class GoogleTranslate extends Operation { let headers = new Headers(); headers.set("Content-Type", "application/json; charset=utf-8"); - ({ url, headers } = applyGCPAuth(url, headers, authType, authStringObj, quotaProject)); + ({ url, headers } = applyGCPAuth(url, headers)); const body = JSON.stringify({ q: input, diff --git a/src/web/html/index.html b/src/web/html/index.html index 38bf7ccc..d5848a83 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -21,889 +21,1054 @@ - - - CyberChef - - - + + + CyberChef - + + + + - - // Load theme before the preloader is shown + + - // Show next loading message and move it to the end of the array - function changeLoadingMsg() { - var msg = loadingMsgs.shift(); - loadingMsgs.push(msg); - try { - var el = document.getElementById("preloader-msg"); - if (!el.classList.contains("loading")) - el.classList.add("loading"); // Causes CSS transition on first message - el.innerHTML = msg; - } catch (err) { - // This error was likely caused by the DOM not being ready yet, - // so we wait another second and then try again. - setTimeout(changeLoadingMsg, 1000); - } - } - - changeLoadingMsg(); - window.loadingMsgsInt = setInterval(changeLoadingMsg, (Math.random() * 2000) + 1500); - - // If any errors are thrown during loading, handle them here - function loadingErrorHandler(e) { - function escapeHtml(str) { - var HTML_CHARS = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", // ' not recommended because it's not in the HTML spec - "/": "/", // forward slash is included as it helps end an HTML entity - "`": "`" - }; - - return str.replace(/[&<>"'/`]/g, function (match) { - return HTML_CHARS[match]; - }); - } - - var msg = e.message + - (e.filename ? "\nFilename: " + e.filename : "") + - (e.lineno ? "\nLine: " + e.lineno : "") + - (e.colno ? "\nColumn: " + e.colno : "") + - (e.error ? "\nError: " + e.error : "") + - "\nUser-Agent: " + navigator.userAgent + - "\nCyberChef version: <%= htmlWebpackPlugin.options.version %>"; - - clearInterval(window.loadingMsgsInt); - document.getElementById("preloader").remove(); - document.getElementById("preloader-msg").remove(); - document.getElementById("preloader-error").innerHTML = - "CyberChef encountered an error while loading.

" + - "The following browser versions are supported:" + - "
  • Google Chrome 50+
  • Mozilla Firefox 38+
" + - "Your user agent is:
" + escapeHtml(navigator.userAgent) + "

" + - "If your browser is supported, please " + - "raise an issue including the following details:

" + - "
" + escapeHtml(msg) + "
"; - }; - window.addEventListener("error", loadingErrorHandler); - - - - -
-
-
-
+ + +
+
+
+
+
+ + +
+ - - -
-