Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 45 additions & 2 deletions src/Commands/RunRepeater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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, '')) {
Expand Down Expand Up @@ -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<DefaultRepeaterServerOptions>(
Expand Down
76 changes: 76 additions & 0 deletions src/RequestExecutor/HttpRequestExecutor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
55 changes: 52 additions & 3 deletions src/RequestExecutor/HttpRequestExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -255,6 +271,7 @@ export class HttpRequestExecutor implements RequestExecutor {

if (
!this.options.reuseConnection &&
!this.shouldApplyKerberos(request) &&
!this.hasHeader(request, 'connection')
) {
curlHeaders.push('Connection: close');
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/RequestExecutor/RequestExecutorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +23,7 @@ export interface RequestExecutorOptions {
reuseConnection?: boolean;
proxyDomains?: string[];
proxyDomainsBypass?: string[];
kerberos?: KerberosOptions;
}

export const RequestExecutorOptions: unique symbol = Symbol(
Expand Down
Loading