Include client IP address in audit log (#148055)

Follow up to #147526 which had to be reverted.

Resolves #127481

## Release notes

Include IP address in audit log

## Testing

1. Start Elasticsearch with trial license: `yarn es snapshot --license
trial`
2. Update `kibana.dev.yaml`:

```yaml
xpack.security.audit.enabled: true
xpack.security.audit.appender:
  type: console
  layout:
    type: json
```

3. Observe audit logs in console when interacting with Kibana:

```json
{
  "@timestamp": "2022-12-13T15:50:42.236+00:00",
  "message": "User is requesting [/dev/internal/security/me] endpoint",
  "client": {
    "ip": "127.0.0.1"
  },
  "http": {
    "request": {
      "headers": {
        "x-forwarded-for": "1.1.1.1, 127.0.0.1"
      }
    }
  }
}
```

Note: You will see the `x-forwarded-for` field populated when running
Kibana in development mode (`yarn start`) since Kibana runs behind a
development proxy.

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Thom Heymann 2023-01-03 17:17:33 +00:00 committed by GitHub
parent 1d0a8c9ddd
commit ee6170be7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 240 additions and 47 deletions

View file

@ -407,11 +407,19 @@ Example: `[marketing]`
| *Field*
| *Description*
| `client.ip`
| Client IP address.
| `http.request.method`
| HTTP request method.
Example: `get`, `post`, `put`, `delete`
| `http.request.headers.x-forwarded-for`
| `X-Forwarded-For` request header used to identify the originating client IP address when connecting through proxy servers.
Example: `161.66.20.177, 236.198.214.101`
| `url.domain`
| Domain of the URL.

View file

@ -153,6 +153,13 @@ describe('KibanaSocket', () => {
});
});
describe('remoteAddress', () => {
it('mirrors the value of net.Socket instance', () => {
const socket = new KibanaSocket({ remoteAddress: '1.1.1.1' } as Socket);
expect(socket.remoteAddress).toBe('1.1.1.1');
});
});
describe('getFakeSocket', () => {
it('returns a stub', async () => {
const fakeSocket = KibanaSocket.getFakeSocket();

View file

@ -28,6 +28,10 @@ export class KibanaSocket implements IKibanaSocket {
return this.socket instanceof TLSSocket ? this.socket.authorizationError : undefined;
}
public get remoteAddress() {
return this.socket.remoteAddress;
}
constructor(private readonly socket: Socket) {}
getPeerCertificate(detailed: true): DetailedPeerCertificate | null;

View file

@ -51,4 +51,11 @@ export interface IKibanaSocket {
* only when `authorized` is `false`.
*/
readonly authorizationError?: Error;
/**
* The string representation of the remote IP address. For example,`'74.125.127.100'` or
* `'2001:4860:a005::68'`. Value may be `undefined` if the socket is destroyed (for example, if
* the client disconnected).
*/
readonly remoteAddress?: string;
}

View file

@ -14,6 +14,77 @@ import type { EcsEventOutcome, EcsEventType, KibanaRequest, LogMeta } from '@kbn
import type { AuthenticationProvider } from '../../common/model';
import type { AuthenticationResult } from '../authentication/authentication_result';
/**
* Audit kibana schema using ECS format
*/
export interface AuditKibana {
/**
* The ID of the space associated with this event.
*/
space_id?: string;
/**
* The ID of the user session associated with this event. Each login attempt
* results in a unique session id.
*/
session_id?: string;
/**
* Saved object that was created, changed, deleted or accessed as part of this event.
*/
saved_object?: {
type: string;
id: string;
};
/**
* Name of authentication provider associated with a login event.
*/
authentication_provider?: string;
/**
* Type of authentication provider associated with a login event.
*/
authentication_type?: string;
/**
* Name of Elasticsearch realm that has authenticated the user.
*/
authentication_realm?: string;
/**
* Name of Elasticsearch realm where the user details were retrieved from.
*/
lookup_realm?: string;
/**
* Set of space IDs that a saved object was shared to.
*/
add_to_spaces?: readonly string[];
/**
* Set of space IDs that a saved object was removed from.
*/
delete_from_spaces?: readonly string[];
}
type EcsHttp = Required<LogMeta>['http'];
type EcsRequest = Required<EcsHttp>['request'];
/**
* Audit request schema using ECS format
*/
export interface AuditRequest extends EcsRequest {
/**
* HTTP request headers
*/
headers?: {
'x-forwarded-for'?: string;
};
}
/**
* Audit http schema using ECS format
*/
export interface AuditHttp extends EcsHttp {
/**
* HTTP request details
*/
request?: AuditRequest;
}
/**
* Audit event schema using ECS format: https://www.elastic.co/guide/en/ecs/1.12/index.html
*
@ -23,49 +94,20 @@ import type { AuthenticationResult } from '../authentication/authentication_resu
* @public
*/
export interface AuditEvent extends LogMeta {
/**
* Log message
*/
message: string;
kibana?: {
/**
* The ID of the space associated with this event.
*/
space_id?: string;
/**
* The ID of the user session associated with this event. Each login attempt
* results in a unique session id.
*/
session_id?: string;
/**
* Saved object that was created, changed, deleted or accessed as part of this event.
*/
saved_object?: {
type: string;
id: string;
};
/**
* Name of authentication provider associated with a login event.
*/
authentication_provider?: string;
/**
* Type of authentication provider associated with a login event.
*/
authentication_type?: string;
/**
* Name of Elasticsearch realm that has authenticated the user.
*/
authentication_realm?: string;
/**
* Name of Elasticsearch realm where the user details were retrieved from.
*/
lookup_realm?: string;
/**
* Set of space IDs that a saved object was shared to.
*/
add_to_spaces?: readonly string[];
/**
* Set of space IDs that a saved object was removed from.
*/
delete_from_spaces?: readonly string[];
};
/**
* Kibana specific fields
*/
kibana?: AuditKibana;
/**
* Fields describing an HTTP request
*/
http?: AuditHttp;
}
export interface HttpRequestParams {

View file

@ -5,8 +5,11 @@
* 2.0.
*/
import type { Socket } from 'net';
import { lastValueFrom, Observable, of } from 'rxjs';
import type { FakeRawRequest } from '@kbn/core/server';
import { CoreKibanaRequest } from '@kbn/core/server';
import {
coreMock,
httpServerMock,
@ -22,6 +25,7 @@ import {
AuditService,
createLoggingConfig,
filterEvent,
getForwardedFor,
RECORD_USAGE_INTERVAL,
} from './audit_service';
@ -173,7 +177,7 @@ describe('#setup', () => {
});
describe('#asScoped', () => {
it('logs event enriched with meta data', async () => {
it('logs event enriched with meta data from request', async () => {
const audit = new AuditService(logger);
const auditSetup = audit.setup({
license,
@ -186,19 +190,78 @@ describe('#asScoped', () => {
recordAuditLoggingUsage,
});
const request = httpServerMock.createKibanaRequest({
socket: { remoteAddress: '3.3.3.3' } as Socket,
headers: {
'x-forwarded-for': '1.1.1.1, 2.2.2.2',
},
kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' },
});
await auditSetup.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } });
expect(logger.info).toHaveBeenCalledWith('MESSAGE', {
await auditSetup.asScoped(request).log({
message: 'MESSAGE',
event: { action: 'ACTION' },
http: { request: { method: 'GET' } },
});
expect(logger.info).toHaveBeenLastCalledWith('MESSAGE', {
event: { action: 'ACTION' },
kibana: { space_id: 'default', session_id: 'SESSION_ID' },
trace: { id: 'REQUEST_ID' },
client: { ip: '3.3.3.3' },
http: {
request: { method: 'GET', headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' } },
},
user: { id: 'uid', name: 'jdoe', roles: ['admin'] },
});
audit.stop();
});
it('logs event enriched with meta data from fake request', async () => {
const audit = new AuditService(logger);
const auditSetup = audit.setup({
license,
config,
logging,
http,
getCurrentUser,
getSpaceId: () => undefined,
getSID: () => Promise.resolve(undefined),
recordAuditLoggingUsage,
});
const fakeRawRequest: FakeRawRequest = {
headers: {},
path: '/',
};
const request = CoreKibanaRequest.from(fakeRawRequest);
await auditSetup.asScoped(request).log({
message: 'MESSAGE',
event: { action: 'ACTION' },
});
expect(logger.info).toHaveBeenLastCalledWith('MESSAGE', {
client: {
ip: undefined,
},
event: {
action: 'ACTION',
},
http: undefined,
kibana: {
session_id: undefined,
space_id: undefined,
},
trace: {
id: expect.any(String),
},
user: {
id: 'uid',
name: 'jdoe',
roles: ['admin'],
},
});
audit.stop();
});
it('does not log to audit logger if event matches ignore filter', async () => {
const audit = new AuditService(logger);
const auditSetup = audit.setup({
@ -424,6 +487,32 @@ describe('#createLoggingConfig', () => {
});
});
describe('#getForwardedFor', () => {
it('extracts x-forwarded-for header from request', () => {
const request = httpServerMock.createKibanaRequest({
headers: {
'x-forwarded-for': '1.1.1.1',
},
});
expect(getForwardedFor(request)).toBe('1.1.1.1');
});
it('concatenates multiple headers into single string in correct order', () => {
const request = httpServerMock.createKibanaRequest({
headers: {
// @ts-expect-error Headers can be arrays but HAPI mocks are incorrectly typed
'x-forwarded-for': ['1.1.1.1, 2.2.2.2', '3.3.3.3'],
},
});
expect(getForwardedFor(request)).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3');
});
it('returns undefined when header not present', () => {
const request = httpServerMock.createKibanaRequest();
expect(getForwardedFor(request)).toBeUndefined();
});
});
describe('#filterEvent', () => {
let event: AuditEvent;

View file

@ -162,6 +162,8 @@ export class AuditService {
const spaceId = getSpaceId(request);
const user = getCurrentUser(request);
const sessionId = await getSID(request);
const forwardedFor = getForwardedFor(request);
log({
...event,
user:
@ -177,6 +179,18 @@ export class AuditService {
...event.kibana,
},
trace: { id: request.id },
client: { ip: request.socket.remoteAddress },
http: forwardedFor
? {
...event.http,
request: {
...event.http?.request,
headers: {
'x-forwarded-for': forwardedFor,
},
},
}
: event.http,
});
},
enabled,
@ -243,3 +257,16 @@ export function filterEvent(
}
return true;
}
/**
* Extracts `X-Forwarded-For` header(s) from `KibanaRequest`.
*/
export function getForwardedFor(request: KibanaRequest) {
const forwardedFor = request.headers['x-forwarded-for'];
if (Array.isArray(forwardedFor)) {
return forwardedFor.join(', ');
}
return forwardedFor;
}

View file

@ -7,7 +7,7 @@
export type { AuditServiceSetup, AuditLogger } from './audit_service';
export { AuditService } from './audit_service';
export type { AuditEvent } from './audit_events';
export type { AuditEvent, AuditHttp, AuditKibana, AuditRequest } from './audit_events';
export {
userLoginEvent,
userLogoutEvent,

View file

@ -10,6 +10,7 @@ export type {
InvalidateAPIKeyResult,
CreateAPIKeyParams,
InvalidateAPIKeysParams,
ValidateAPIKeyParams,
GrantAPIKeyResult,
} from './api_keys';
export { APIKeys, CreateApiKeyValidationError } from './api_keys';

View file

@ -32,5 +32,6 @@ export type {
InvalidateAPIKeyResult,
CreateAPIKeyParams,
InvalidateAPIKeysParams,
ValidateAPIKeyParams,
GrantAPIKeyResult,
} from './api_keys';

View file

@ -26,11 +26,12 @@ export type {
InvalidateAPIKeysParams,
InvalidateAPIKeyResult,
GrantAPIKeyResult,
ValidateAPIKeyParams,
AuthenticationServiceStart,
} from './authentication';
export type { CheckPrivilegesPayload, CasesSupportedOperations } from './authorization';
export type AuthorizationServiceSetup = SecurityPluginStart['authz'];
export type { AuditLogger, AuditEvent } from './audit';
export type { AuditLogger, AuditEvent, AuditHttp, AuditKibana, AuditRequest } from './audit';
export type { SecurityPluginSetup, SecurityPluginStart };
export type { AuthenticatedUser } from '../common/model';
export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags';

View file

@ -55,6 +55,7 @@ export default function ({ getService }: FtrProviderContext) {
await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.set('X-Forwarded-For', '1.1.1.1, 2.2.2.2')
.send({
providerType: 'basic',
providerName: 'basic',
@ -71,12 +72,15 @@ export default function ({ getService }: FtrProviderContext) {
expect(loginEvent.event.outcome).to.be('success');
expect(loginEvent.trace.id).to.be.ok();
expect(loginEvent.user.name).to.be(username);
expect(loginEvent.client.ip).to.be.ok();
expect(loginEvent.http.request.headers['x-forwarded-for']).to.be('1.1.1.1, 2.2.2.2');
});
it('logs audit events when failing to log in', async () => {
await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.set('X-Forwarded-For', '1.1.1.1, 2.2.2.2')
.send({
providerType: 'basic',
providerName: 'basic',
@ -93,6 +97,8 @@ export default function ({ getService }: FtrProviderContext) {
expect(loginEvent.event.outcome).to.be('failure');
expect(loginEvent.trace.id).to.be.ok();
expect(loginEvent.user).not.to.be.ok();
expect(loginEvent.client.ip).to.be.ok();
expect(loginEvent.http.request.headers['x-forwarded-for']).to.be('1.1.1.1, 2.2.2.2');
});
});
}