From 949c4a445aeb6d917413a541b3245c7ce5ed87b3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 20 Feb 2026 08:48:27 -0800 Subject: [PATCH] Fix NTLM challenge parsing when WWW-Authenticate has Negotiate first (#402) --- plugins/auth-ntlm/package.json | 3 +- plugins/auth-ntlm/src/index.ts | 21 ++++--- plugins/auth-ntlm/tests/index.test.ts | 84 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 plugins/auth-ntlm/tests/index.test.ts diff --git a/plugins/auth-ntlm/package.json b/plugins/auth-ntlm/package.json index 337fb8c4..3d0b6e16 100644 --- a/plugins/auth-ntlm/package.json +++ b/plugins/auth-ntlm/package.json @@ -11,7 +11,8 @@ "version": "0.1.0", "scripts": { "build": "yaakcli build", - "dev": "yaakcli dev" + "dev": "yaakcli dev", + "test": "vitest --run tests" }, "dependencies": { "httpntlm": "^1.8.13" diff --git a/plugins/auth-ntlm/src/index.ts b/plugins/auth-ntlm/src/index.ts index 47aa4cbb..acf355d4 100644 --- a/plugins/auth-ntlm/src/index.ts +++ b/plugins/auth-ntlm/src/index.ts @@ -2,6 +2,16 @@ import type { PluginDefinition } from '@yaakapp/api'; import { ntlm } from 'httpntlm'; +function extractNtlmChallenge(headers: Array<{ name: string; value: string }>): string | null { + const authValues = headers + .filter((h) => h.name.toLowerCase() === 'www-authenticate') + .flatMap((h) => h.value.split(',')) + .map((v) => v.trim()) + .filter(Boolean); + + return authValues.find((v) => /^NTLM\s+\S+/i.test(v)) ?? null; +} + export const plugin: PluginDefinition = { authentication: { name: 'windows', @@ -68,15 +78,12 @@ export const plugin: PluginDefinition = { }, }); - const wwwAuthenticateHeader = negotiateResponse.headers.find( - (h) => h.name.toLowerCase() === 'www-authenticate', - ); - - if (!wwwAuthenticateHeader?.value) { - throw new Error('Unable to find www-authenticate response header for NTLM'); + const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers); + if (ntlmChallenge == null) { + throw new Error('Unable to find NTLM challenge in WWW-Authenticate response headers'); } - const type2 = ntlm.parseType2Message(wwwAuthenticateHeader.value, (err: Error | null) => { + const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => { if (err != null) throw err; }); const type3 = ntlm.createType3Message(type2, options); diff --git a/plugins/auth-ntlm/tests/index.test.ts b/plugins/auth-ntlm/tests/index.test.ts new file mode 100644 index 00000000..e590a80c --- /dev/null +++ b/plugins/auth-ntlm/tests/index.test.ts @@ -0,0 +1,84 @@ +import type { Context } from '@yaakapp/api'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const ntlmMock = vi.hoisted(() => ({ + createType1Message: vi.fn(), + parseType2Message: vi.fn(), + createType3Message: vi.fn(), +})); + +vi.mock('httpntlm', () => ({ ntlm: ntlmMock })); + +import { plugin } from '../src'; + +describe('auth-ntlm', () => { + beforeEach(() => { + ntlmMock.createType1Message.mockReset(); + ntlmMock.parseType2Message.mockReset(); + ntlmMock.createType3Message.mockReset(); + ntlmMock.createType1Message.mockReturnValue('NTLM TYPE1'); + ntlmMock.parseType2Message.mockReturnValue({} as any); + ntlmMock.createType3Message.mockReturnValue('NTLM TYPE3'); + }); + + test('uses NTLM challenge when Negotiate and NTLM headers are separate', async () => { + const send = vi.fn().mockResolvedValue({ + headers: [ + { name: 'WWW-Authenticate', value: 'Negotiate' }, + { name: 'WWW-Authenticate', value: 'NTLM TlRMTVNTUAACAAAAAA==' }, + ], + }); + const ctx = { httpRequest: { send } } as unknown as Context; + + const result = await plugin.authentication?.onApply(ctx, { + values: {}, + headers: [], + url: 'https://example.local/resource', + method: 'GET', + contextId: 'ctx', + }); + + expect(ntlmMock.parseType2Message).toHaveBeenCalledWith( + 'NTLM TlRMTVNTUAACAAAAAA==', + expect.any(Function), + ); + expect(result).toEqual({ setHeaders: [{ name: 'Authorization', value: 'NTLM TYPE3' }] }); + }); + + test('uses NTLM challenge when auth schemes are comma-separated in one header', async () => { + const send = vi.fn().mockResolvedValue({ + headers: [{ name: 'www-authenticate', value: 'Negotiate, NTLM TlRMTVNTUAACAAAAAA==' }], + }); + const ctx = { httpRequest: { send } } as unknown as Context; + + await plugin.authentication?.onApply(ctx, { + values: {}, + headers: [], + url: 'https://example.local/resource', + method: 'GET', + contextId: 'ctx', + }); + + expect(ntlmMock.parseType2Message).toHaveBeenCalledWith( + 'NTLM TlRMTVNTUAACAAAAAA==', + expect.any(Function), + ); + }); + + test('throws a clear error when NTLM challenge is missing', async () => { + const send = vi.fn().mockResolvedValue({ + headers: [{ name: 'WWW-Authenticate', value: 'Negotiate' }], + }); + const ctx = { httpRequest: { send } } as unknown as Context; + + await expect( + plugin.authentication?.onApply(ctx, { + values: {}, + headers: [], + url: 'https://example.local/resource', + method: 'GET', + contextId: 'ctx', + }), + ).rejects.toThrow('Unable to find NTLM challenge in WWW-Authenticate response headers'); + }); +});