From 45b09ac74a911ed51005180214fee3660f46c5b0 Mon Sep 17 00:00:00 2001 From: mohammad5525 Date: Mon, 24 Aug 2026 13:55:03 +0100 Subject: [PATCH] Fix OAuth token-endpoint injection in the Adobe Sign and Zoho CRM samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Adobe Sign and Zoho CRM samples read a host/URL from an OAuth callback query parameter (api_access_point / accounts-server) and pass it straight into setTokenUrl(), then persist it. In a web-app or add-on deployment the callback is reachable with attacker-chosen parameters, so a single crafted GET makes the Apps Script server POST the token exchange — authorization code, client_id and client_secret — to an arbitrary host, and every later token refresh is sent there as well. In the Adobe Sign sample the second setPropertyStore call additionally moves the stored state into shared ScriptProperties, so one visitor's crafted authorization persists the attacker endpoint for all other users of the deployment. This change: - Validates the api_access_point / accounts-server parameter against the vendor's own domains (Adobe: adobe.com / adobesign.com / echosign.com; Zoho: accounts.zoho.*) and rejects anything else before it reaches setTokenUrl() or storage. - Removes the second setPropertyStore call in the Adobe Sign sample so tokens stay in per-user properties. Verified against the live attack chain from the report that motivated this fix (Google VRP iss.ee/551231684): after the change the crafted callback is rejected (returns "Denied.") without any outbound token request, while a legitimate vendor access point continues to work. --- samples/AdobeSign.gs | 26 +++++++++++++++++++++++++- samples/ZohoCRM.gs | 16 ++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/samples/AdobeSign.gs b/samples/AdobeSign.gs index 688cc65c..13130165 100644 --- a/samples/AdobeSign.gs +++ b/samples/AdobeSign.gs @@ -58,8 +58,10 @@ function getService_(optApiAccessPoint) { .setCallbackFunction('authCallback') // Set the property store where authorized tokens should be persisted. + // Use per-user properties only: script properties are shared by every + // user of a deployment, which would leak one user's tokens and stored + // API access point to all other users. .setPropertyStore(PropertiesService.getUserProperties()) - .setPropertyStore(PropertiesService.getScriptProperties()) // Set the scopes. .setScope('user_read'); @@ -82,6 +84,13 @@ function getService_(optApiAccessPoint) { function authCallback(request) { // Get the API access point specified in the URL parameters. var apiAccessPoint = request.parameter[API_ACCESS_POINT_KEY]; + // Only accept API access points on Adobe-owned hosts. This parameter is + // attacker-controllable in web-app/add-on deployments, and an unchecked + // value sends the token exchange (code, client_id, client_secret) and all + // later refreshes to an arbitrary host. + if (apiAccessPoint && !isValidApiAccessPoint_(apiAccessPoint)) { + return HtmlService.createHtmlOutput('Denied.'); + } var service = getService_(apiAccessPoint); var authorized = service.handleCallback(request); if (authorized) { @@ -93,6 +102,21 @@ function authCallback(request) { } } +/** + * Determines whether the given API access point is on an Adobe-owned host. + * @param {string} apiAccessPoint The API access point URL to validate. + * @return {boolean} True if the host is an Adobe domain served over HTTPS. + */ +function isValidApiAccessPoint_(apiAccessPoint) { + var match = /^https:\/\/([a-zA-Z0-9.-]+)(\/|$)/.exec(apiAccessPoint); + if (!match) { + return false; + } + var host = match[1].toLowerCase(); + return /(^|\.)adobe\.com$/.test(host) || /(^|\.)adobesign\.com$/.test(host) || + /(^|\.)echosign\.com$/.test(host); +} + /** * Logs the redict URI to register in the Dropbox application settings. */ diff --git a/samples/ZohoCRM.gs b/samples/ZohoCRM.gs index 7428cdb3..d1dcb47f 100644 --- a/samples/ZohoCRM.gs +++ b/samples/ZohoCRM.gs @@ -81,6 +81,13 @@ function getService_(optAccountServer) { */ function authCallback(request) { var accountServer = request.parameter['accounts-server']; + // Only accept account servers on Zoho-owned hosts. This parameter is + // attacker-controllable in web-app/add-on deployments, and an unchecked + // value sends the token exchange (code, client_id, client_secret) and all + // later refreshes to an arbitrary host. + if (accountServer && !isValidAccountServer_(accountServer)) { + return HtmlService.createHtmlOutput('Denied.'); + } var service = getService_(accountServer); var authorized = service.handleCallback(request); if (authorized) { @@ -92,6 +99,15 @@ function authCallback(request) { } } +/** + * Determines whether the given account server is a Zoho accounts host. + * @param {string} accountServer The account server URL to validate. + * @return {boolean} True if it is an https accounts.zoho.* host. + */ +function isValidAccountServer_(accountServer) { + return /^https:\/\/accounts\.zoho\.[a-z.]+\/?$/.test(accountServer); +} + /** * Logs the redict URI to register. */