diff --git a/Dockerfile b/Dockerfile index e2bbc6c2..f92463e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,9 +41,16 @@ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositori apk upgrade --no-cache # install @brightsec/cli from NPM +# NOTE: Kerberos/SPNEGO support requires @brightsec/node-libcurl to be built +# with the "gssapi" vcpkg feature. The krb5 package provides kinit/klist +# for ticket management; krb5-libs provides the runtime GSSAPI libraries +# that libcurl links against when built with gssapi support. RUN set -eux; \ + apk add --no-cache --virtual .build-deps python3 make g++ curl-dev && \ npm i -g -q @brightsec/cli@${VERSION} && \ - NPM_CONFIG_PREFIX=/usr/local npm uninstall -g npm + NPM_CONFIG_PREFIX=/usr/local npm uninstall -g npm && \ + apk del .build-deps && \ + apk add --no-cache libcurl krb5-libs krb5 # set the directory and file permissions to allow users in the root group to access files # for details please refer to the doc at https://docs.openshift.com/container-platform/3.11/creating_images/guidelines.html#openshift-specific-guidelines diff --git a/src/Commands/RunRepeater.ts b/src/Commands/RunRepeater.ts index c24edb03..8b86c89f 100644 --- a/src/Commands/RunRepeater.ts +++ b/src/Commands/RunRepeater.ts @@ -119,6 +119,37 @@ export class RunRepeater implements CommandModule { boolean: true, describe: 'Configure ntlm support (enables TCP connection reuse)' }) + .option('kerberos', { + boolean: true, + describe: 'Enable Kerberos/SPNEGO authentication for target requests' + }) + .option('kerberos-domains', { + requiresArg: true, + array: true, + describe: + 'Space-separated list of domains that should use Kerberos authentication. If omitted, applies to all domains.', + coerce(arg: string[]): string[] { + if (arg[0] === undefined) { + return undefined; + } + + if (arg.length === 1 && arg[0].includes(' ')) { + return arg[0].trim().split(' '); + } + + return arg; + } + }) + .option('kerberos-credentials', { + requiresArg: true, + string: true, + describe: + 'Kerberos credentials in user:password format. If omitted, system keytab or existing ticket (kinit) is used.' + }) + .option('kerberos-delegation', { + boolean: true, + describe: 'Allow Kerberos credential delegation to the target server' + }) .option('daemon', { requiresArg: false, alias: 'd', @@ -182,9 +213,13 @@ export class RunRepeater implements CommandModule { }) .conflicts({ daemon: 'remove-daemon', - ntlm: ['proxy', 'experimental-connection-reuse'] + ntlm: ['proxy', 'experimental-connection-reuse', 'kerberos'], + kerberos: ['ntlm'] }) .conflicts('proxy-domains', 'proxy-domains-bypass') + .implies('kerberos-domains', 'kerberos') + .implies('kerberos-credentials', 'kerberos') + .implies('kerberos-delegation', 'kerberos') .env('REPEATER') .middleware((args: Arguments) => { if (Object.hasOwnProperty.call(args, '')) { @@ -256,7 +291,15 @@ export class RunRepeater implements CommandModule { { type: 'application/graphql', allowTruncation: false } ], proxyDomains: args.proxyDomains as string[], - proxyDomainsBypass: args.proxyDomainsBypass as string[] + proxyDomainsBypass: args.proxyDomainsBypass as string[], + kerberos: args.kerberos + ? { + enabled: true, + domains: args.kerberosDomains as string[], + credentials: args.kerberosCredentials as string, + delegation: !!args.kerberosDelegation + } + : undefined } }) .register( diff --git a/src/RequestExecutor/HttpRequestExecutor.spec.ts b/src/RequestExecutor/HttpRequestExecutor.spec.ts index c85c0c52..80f66a9a 100644 --- a/src/RequestExecutor/HttpRequestExecutor.spec.ts +++ b/src/RequestExecutor/HttpRequestExecutor.spec.ts @@ -1229,4 +1229,80 @@ describe('HttpRequestExecutor', () => { // assert expect(response.body).toEqual('original'); }); + + describe('Kerberos authentication', () => { + it('should handle kerberos-enabled requests gracefully', async () => { + // When kerberos is enabled, the executor sets HTTPAUTH=Negotiate and + // activates connection reuse (shared Multi) for SPNEGO handshake. + // If GSSAPI is unavailable, curl returns an error response (not a throw). + const { baseUrl } = await startServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + }); + const sut = buildSut({ + kerberos: { enabled: true } + }); + const { request } = createRequest({ url: `${baseUrl}/a` }); + + // Must not throw — returns either 200 (if GSSAPI works) or error response + const response = await sut.execute(request); + + expect(response).toBeDefined(); + expect(response.protocol).toBe(Protocol.HTTP); + }); + + it('should not apply kerberos when kerberos is not enabled', async () => { + // arrange + let connectionCount = 0; + const { server, baseUrl } = await startServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + }); + server.on('connection', () => { + connectionCount++; + }); + const sut = buildSut({}); + const { request: req1 } = createRequest({ url: `${baseUrl}/a` }); + const { request: req2 } = createRequest({ url: `${baseUrl}/b` }); + + // act + await sut.execute(req1); + await sut.execute(req2); + + // assert — no connection reuse without kerberos or reuseConnection + expect(connectionCount).toBe(2); + }); + + it('should only apply kerberos to matching domains when kerberos-domains is specified', async () => { + // arrange + let connectionCount = 0; + const { server, baseUrl } = await startServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + }); + server.on('connection', () => { + connectionCount++; + }); + + // kerberos only for *.example.com — 127.0.0.1 won't match + const sut = buildSut({ + kerberos: { + enabled: true, + domains: ['*.example.com'] + } + }); + + // Requests to 127.0.0.1 do NOT match *.example.com, + // so kerberos won't apply and connections won't be reused + const { request: req1 } = createRequest({ url: `${baseUrl}/a` }); + const { request: req2 } = createRequest({ url: `${baseUrl}/b` }); + + // act + await sut.execute(req1); + await sut.execute(req2); + + // assert — no kerberos applied, so FRESH_CONNECT used + expect(connectionCount).toBe(2); + }); + }); }); diff --git a/src/RequestExecutor/HttpRequestExecutor.ts b/src/RequestExecutor/HttpRequestExecutor.ts index 788b8abd..9471080d 100644 --- a/src/RequestExecutor/HttpRequestExecutor.ts +++ b/src/RequestExecutor/HttpRequestExecutor.ts @@ -10,7 +10,14 @@ import { CertificatesResolver } from './CertificatesResolver'; import { inject, injectable } from 'tsyringe'; import iconv from 'iconv-lite'; import { safeParse } from 'fast-content-type-parse'; -import { Curl, CurlFeature, HeaderInfo, Multi } from '@brightsec/node-libcurl'; +import { + Curl, + CurlAuth, + CurlFeature, + CurlGssApi, + HeaderInfo, + Multi +} from '@brightsec/node-libcurl'; import { parse as parseUrl } from 'node:url'; type ScriptEntrypoint = ( @@ -32,6 +39,7 @@ export class HttpRequestExecutor implements RequestExecutor { private readonly RESPONSE_SCRIPT_ENTRYPOINT = 'onResponse'; private readonly proxyDomains?: RegExp[]; private readonly proxyDomainsBypass?: RegExp[]; + private readonly kerberosDomains?: RegExp[]; private readonly sharedMulti = new Multi(); get protocol(): Protocol { @@ -68,7 +76,13 @@ export class HttpRequestExecutor implements RequestExecutor { ); } - if (this.options.reuseConnection) { + if (this.options.kerberos?.enabled && this.options.kerberos.domains) { + this.kerberosDomains = this.options.kerberos.domains.map((domain) => + Helpers.wildcardToRegExp(domain) + ); + } + + if (this.options.reuseConnection || this.options.kerberos?.enabled) { this.sharedMulti.setOpt( 'MAX_HOST_CONNECTIONS', this.MAX_HOST_CONNECTIONS @@ -175,7 +189,7 @@ export class HttpRequestExecutor implements RequestExecutor { this.applyCurlTimeout(curl, options); this.applyCurlHeaders(curl, options); - if (this.options.reuseConnection) { + if (this.options.reuseConnection || this.shouldApplyKerberos(options)) { curl.setOpt('TCP_KEEPALIVE', 1); curl.setOpt('TCP_KEEPIDLE', this.KEEP_ALIVE_IDLE_TIMEOUT); curl.setMulti(this.sharedMulti); @@ -184,6 +198,8 @@ export class HttpRequestExecutor implements RequestExecutor { curl.setOpt('FORBID_REUSE', 1); } + this.applyCurlKerberos(curl, options); + const proxyUrl = this.resolveProxy(options); if (proxyUrl) { @@ -255,6 +271,7 @@ export class HttpRequestExecutor implements RequestExecutor { if ( !this.options.reuseConnection && + !this.shouldApplyKerberos(request) && !this.hasHeader(request, 'connection') ) { curlHeaders.push('Connection: close'); @@ -340,6 +357,38 @@ export class HttpRequestExecutor implements RequestExecutor { return this.options.proxyUrl; } + private shouldApplyKerberos(options: Request): boolean { + if (!this.options.kerberos?.enabled) { + return false; + } + + if (!this.kerberosDomains) { + return true; + } + + const hostname = parseUrl(options.url).hostname; + + return this.kerberosDomains.some((domain) => domain.test(hostname)); + } + + private applyCurlKerberos(curl: Curl, options: Request): void { + if (!this.shouldApplyKerberos(options)) { + return; + } + + curl.setOpt('HTTPAUTH', CurlAuth.Negotiate); + curl.setOpt('USERPWD', this.options.kerberos.credentials || ':'); + + if (this.options.kerberos.delegation) { + curl.setOpt('GSSAPI_DELEGATION', CurlGssApi.DelegationFlag); + } + + logger.debug( + 'Applying Kerberos/SPNEGO authentication for URL "%s"', + options.url + ); + } + private truncateResponse( { decompress, encoding, maxContentSize, url }: Request, rawBody: Buffer, diff --git a/src/RequestExecutor/RequestExecutorOptions.ts b/src/RequestExecutor/RequestExecutorOptions.ts index 9579324c..65326f1d 100644 --- a/src/RequestExecutor/RequestExecutorOptions.ts +++ b/src/RequestExecutor/RequestExecutorOptions.ts @@ -5,6 +5,13 @@ export interface WhitelistMimeType { allowTruncation?: boolean; } +export interface KerberosOptions { + enabled: boolean; + domains?: string[]; + credentials?: string; + delegation?: boolean; +} + export interface RequestExecutorOptions { timeout?: number; proxyUrl?: string; @@ -16,6 +23,7 @@ export interface RequestExecutorOptions { reuseConnection?: boolean; proxyDomains?: string[]; proxyDomainsBypass?: string[]; + kerberos?: KerberosOptions; } export const RequestExecutorOptions: unique symbol = Symbol(