From 8ac934599a976d0540913cf61296eebcda6dba69 Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Wed, 30 Oct 2024 04:42:19 -0700 Subject: [PATCH] fix: process headers correctly in Digest Auth and other updates (#4494) --- .../src/__tests__/e2e/commands/test.spec.ts | 18 +++++++- .../collections/digest-auth-coll.json | 43 ------------------- .../hoppscotch-cli/src/utils/auth/digest.ts | 43 +++++++++++++------ .../hoppscotch-cli/src/utils/pre-request.ts | 18 ++++---- packages/hoppscotch-cli/src/utils/request.ts | 12 ++++-- .../components/http/authorization/Digest.vue | 5 ++- .../src/helpers/auth/digest.ts | 41 ++++++++++++------ .../src/helpers/utils/EffectiveURL.ts | 10 ++++- 8 files changed, 105 insertions(+), 85 deletions(-) delete mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index e663ff739..a2fe12235 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -422,7 +422,7 @@ describe("hopp test [options] ", () => { describe("Digest Authorization type", () => { test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { const COLL_PATH = getTestJsonFilePath( - "digest-auth-coll.json", + "digest-auth-success-coll.json", "collection" ); const ENVS_PATH = getTestJsonFilePath( @@ -436,6 +436,22 @@ describe("hopp test [options] ", () => { expect(error).toBeNull(); }); }); + + test("Supports disabling request retries", async () => { + const COLL_PATH = getTestJsonFilePath( + "digest-auth-failure-coll.json", + "collection" + ); + const ENVS_PATH = getTestJsonFilePath( + "digest-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENVS_PATH}`; + const { error } = await runCLI(args); + + expect(error).toBeTruthy(); + }); }); describe("Test `hopp test --delay ` command:", () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json deleted file mode 100644 index a3d020aa6..000000000 --- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "v": 3, - "name": "Digest Auth - collection", - "folders": [], - "requests": [ - { - "v": "8", - "id": "cm0dm70cw000687bnxi830zz7", - "auth": { - "authType": "digest", - "authActive": true, - "username": "<>", - "password": "<>", - "realm": "", - "nonce": "", - "algorithm": "MD5", - "qop": "auth", - "nc": "", - "cnonce": "", - "opaque": "", - "disableRetry": false - }, - "body": { - "body": null, - "contentType": null - }, - "name": "digest-auth-headers", - "method": "GET", - "params": [], - "headers": [], - "endpoint": "<>", - "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});", - "preRequestScript": "", - "responses": {}, - "requestVariables": [] - } - ], - "auth": { - "authType": "inherit", - "authActive": true - }, - "headers": [] -} diff --git a/packages/hoppscotch-cli/src/utils/auth/digest.ts b/packages/hoppscotch-cli/src/utils/auth/digest.ts index 5120535f3..7c8d65ddd 100644 --- a/packages/hoppscotch-cli/src/utils/auth/digest.ts +++ b/packages/hoppscotch-cli/src/utils/auth/digest.ts @@ -15,6 +15,7 @@ export interface DigestAuthParams { nc?: string; opaque?: string; cnonce?: string; // client nonce (optional but typically required in qop='auth') + reqBody?: string; } export interface DigestAuthInfo { @@ -55,18 +56,28 @@ export const generateDigestAuthHeader = async (params: DigestAuthParams) => { nc = "00000001", opaque, cnonce, + reqBody = "", } = params; - const uri = endpoint.replace(/(^\w+:|^)\/\//, ""); + const url = new URL(endpoint); + const uri = url.pathname + url.search; // Generate client nonce if not provided const generatedCnonce = cnonce || md5(`${Math.random()}`); - // Step 1: Hash the username, realm, and password - const ha1 = md5(`${username}:${realm}:${password}`); + // Step 1: Hash the username, realm, password and any additional fields based on the algorithm + const ha1 = + algorithm === "MD5-sess" + ? md5( + `${md5(`${username}:${realm}:${password}`)}:${nonce}:${generatedCnonce}` + ) + : md5(`${username}:${realm}:${password}`); // Step 2: Hash the method and URI - const ha2 = md5(`${method}:${uri}`); + const ha2 = + qop === "auth-int" + ? md5(`${method}:${uri}:${md5(reqBody)}`) // Entity body hash for `auth-int` + : md5(`${method}:${uri}`); // Step 3: Compute the response hash const response = md5( @@ -95,9 +106,21 @@ export const fetchInitialDigestAuthInfo = async ( validateStatus: () => true, // Allow handling of all status codes }); + if (disableRetry) { + throw new Error( + `Received status: ${initialResponse.status}. Retry is disabled as specified, so no further attempts will be made.` + ); + } + // Check if the response status is 401 (which is expected in Digest Auth flow) - if (initialResponse.status === 401 && !disableRetry) { - const authHeader = initialResponse.headers["www-authenticate"]; + if (initialResponse.status === 401) { + const authHeaderEntry = Object.keys(initialResponse.headers).find( + (header) => header.toLowerCase() === "www-authenticate" + ); + + const authHeader = authHeaderEntry + ? (initialResponse.headers[authHeaderEntry] ?? null) + : null; if (authHeader) { const authParams = parseDigestAuthHeader(authHeader); @@ -119,13 +142,9 @@ export const fetchInitialDigestAuthInfo = async ( throw new Error( "Failed to parse authentication parameters from WWW-Authenticate header" ); - } else if (initialResponse.status === 401 && disableRetry) { - throw new Error( - `401 Unauthorized received. Retry is disabled as specified, so no further attempts will be made.` - ); - } else { - throw new Error(`Unexpected response: ${initialResponse.status}`); } + + throw new Error(`Unexpected response: ${initialResponse.status}`); } catch (error) { const errMsg = error instanceof Error ? error.message : error; diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 75438b3bf..6ec0c5eb0 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -120,6 +120,15 @@ export async function getEffectiveRESTRequest( } const effectiveFinalParams = _effectiveFinalParams.right; + // Parsing final-body with applied ENVs. + const _effectiveFinalBody = getFinalBodyFromRequest( + request, + resolvedVariables + ); + if (E.isLeft(_effectiveFinalBody)) { + return _effectiveFinalBody; + } + // Authentication if (request.auth.authActive) { // TODO: Support a better b64 implementation than btoa ? @@ -266,6 +275,7 @@ export async function getEffectiveRESTRequest( opaque: request.auth.opaque ? parseTemplateString(request.auth.opaque, resolvedVariables) : authInfo.opaque, + reqBody: typeof request.body.body === "string" ? request.body.body : "", }; // Step 3: Generate the Authorization header @@ -280,14 +290,6 @@ export async function getEffectiveRESTRequest( } } - // Parsing final-body with applied ENVs. - const _effectiveFinalBody = getFinalBodyFromRequest( - request, - resolvedVariables - ); - if (E.isLeft(_effectiveFinalBody)) { - return _effectiveFinalBody; - } const effectiveFinalBody = _effectiveFinalBody.right; if ( diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 97bd02041..a46cd0694 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -240,7 +240,9 @@ export const processRequest = // Updating report for errors & current result report.errors.push(preRequestRes.left); - report.result = report.result; + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; } else { // Updating effective-request and consuming updated envs after pre-request script execution ({ effectiveRequest, updatedEnvs } = preRequestRes.right); @@ -268,7 +270,9 @@ export const processRequest = if (E.isLeft(requestRunnerRes)) { // Updating report for errors & current result report.errors.push(requestRunnerRes.left); - report.result = report.result; + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; printRequestRunner.fail(); } else { @@ -291,7 +295,9 @@ export const processRequest = // Updating report with current errors & result. report.errors.push(testRunnerRes.left); - report.result = report.result; + + // Ensure, the CLI fails with a non-zero exit code if there are any errors + report.result = false; } else { const { envs, testsReport, duration } = testRunnerRes.right; const _hasFailedTestCases = hasFailedTestCases(testsReport); diff --git a/packages/hoppscotch-common/src/components/http/authorization/Digest.vue b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue index 1f29843e0..149bcaf66 100644 --- a/packages/hoppscotch-common/src/components/http/authorization/Digest.vue +++ b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue @@ -129,14 +129,15 @@ /> -
+ +
diff --git a/packages/hoppscotch-common/src/helpers/auth/digest.ts b/packages/hoppscotch-common/src/helpers/auth/digest.ts index 92f4a68d8..c74f92d71 100644 --- a/packages/hoppscotch-common/src/helpers/auth/digest.ts +++ b/packages/hoppscotch-common/src/helpers/auth/digest.ts @@ -17,6 +17,7 @@ export interface DigestAuthParams { nc?: string opaque?: string cnonce?: string // client nonce (optional but typically required in qop='auth') + reqBody?: string } // Function to generate Digest Auth Header @@ -33,18 +34,28 @@ export async function generateDigestAuthHeader(params: DigestAuthParams) { nc = "00000001", opaque, cnonce, + reqBody = " ", } = params - const uri = endpoint.replace(/(^\w+:|^)\/\//, "") + const url = new URL(endpoint) + const uri = url.pathname + url.search // Generate client nonce if not provided const generatedCnonce = cnonce || md5(`${Math.random()}`) - // Step 1: Hash the username, realm, and password - const ha1 = md5(`${username}:${realm}:${password}`) + // Step 1: Hash the username, realm, password and any additional fields based on the algorithm + const ha1 = + algorithm === "MD5-sess" + ? md5( + `${md5(`${username}:${realm}:${password}`)}:${nonce}:${generatedCnonce}` + ) + : md5(`${username}:${realm}:${password}`) // Step 2: Hash the method and URI - const ha2 = md5(`${method}:${uri}`) + const ha2 = + qop === "auth-int" + ? md5(`${method}:${uri}:${md5(reqBody)}`) // Entity body hash for `auth-int` + : md5(`${method}:${uri}`) // Step 3: Compute the response hash const response = md5(`${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}`) @@ -69,8 +80,7 @@ export interface DigestAuthInfo { export async function fetchInitialDigestAuthInfo( url: string, - method: string, - disableRetry: boolean + method: string ): Promise { const t = getI18n() @@ -91,8 +101,14 @@ export async function fetchInitialDigestAuthInfo( } // Check if the response status is 401 (which is expected in Digest Auth flow) - if (initialResponse.right.status === 401 && !disableRetry) { - const authHeader = initialResponse.right.headers["www-authenticate"] + if (initialResponse.right.status === 401) { + const authHeaderEntry = Object.keys(initialResponse.right.headers).find( + (header) => header.toLowerCase() === "www-authenticate" + ) + + const authHeader = authHeaderEntry + ? (initialResponse.right.headers[authHeaderEntry] ?? null) + : null if (authHeader) { const authParams = parseDigestAuthHeader(authHeader) @@ -111,16 +127,13 @@ export async function fetchInitialDigestAuthInfo( } } } + throw new Error( "Failed to parse authentication parameters from WWW-Authenticate header" ) - } else if (initialResponse.right.status === 401 && disableRetry) { - throw new Error( - `401 Unauthorized received. Retry is disabled as specified, so no further attempts will be made.` - ) - } else { - throw new Error(`Unexpected response: ${initialResponse.right.status}`) } + + throw new Error(`Unexpected response: ${initialResponse.right.status}`) } catch (error) { const errMsg = error instanceof Error ? error.message : error diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index cac682b09..6cb21c427 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -111,8 +111,13 @@ export const getComputedAuthHeaders = async ( // Step 1: Fetch the initial auth info (nonce, realm, etc.) const authInfo = await fetchInitialDigestAuthInfo( parseTemplateString(endpoint, envVars), - method, - request.auth.disableRetry + method + ) + + const reqBody = getFinalBodyFromRequest( + req as HoppRESTRequest, + envVars, + showKeyIfSecret ) // Step 2: Set up the parameters for the digest authentication header @@ -134,6 +139,7 @@ export const getComputedAuthHeaders = async ( opaque: request.auth.opaque ? parseTemplateString(request.auth.opaque, envVars) : authInfo.opaque, + reqBody: typeof reqBody === "string" ? reqBody : "", } // Step 3: Generate the Authorization header