Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased 3.x]
### Added
- Add support for Basic Authentication in webhook audit log sink using `plugins.security.audit.config.username` and `plugins.security.audit.config.password` ([#5792](https://github.com/opensearch-project/security/pull/5792))

### Changed
- Ensure all restHeaders from ActionPlugin.getRestHeaders are carried to threadContext for tracing ([#5396](https://github.com/opensearch-project/security/pull/5396))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1827,14 +1827,14 @@ public List<Setting<?>> getSettings() {
); // not filtered here
settings.add(
Setting.simpleString(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME,
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME,
Property.NodeScope,
Property.Filtered
)
);
settings.add(
Setting.simpleString(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD,
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD,
Property.NodeScope,
Property.Filtered
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ public ExternalOpenSearchSink(
ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL_CLIENT_AUTH,
ConfigConstants.OPENDISTRO_SECURITY_AUDIT_SSL_ENABLE_SSL_CLIENT_AUTH_DEFAULT
);
final String user = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME);
final String password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD);
final String user = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME);
final String password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD);

final HttpClientBuilder builder = HttpClient.builder(servers.toArray(new String[0]));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLContext;

Expand Down Expand Up @@ -60,6 +61,9 @@ public class WebhookSink extends AuditLogSink {
WebhookFormat webhookFormat = null;
final boolean verifySSL;
final KeyStore effectiveTruststore;
private final String username;
private final String password;
private final String basicAuthHeader;

public WebhookSink(
final String name,
Expand All @@ -77,6 +81,19 @@ public WebhookSink(
final String webhookUrl = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_WEBHOOK_URL);
final String format = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_WEBHOOK_FORMAT);

// Read basic auth credentials
this.username = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME);
this.password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD);

// Generate Basic Auth header if credentials are provided
if (this.username != null && this.password != null) {
String credentials = this.username + ":" + this.password;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
this.basicAuthHeader = "Basic " + encodedCredentials;
} else {
this.basicAuthHeader = null;
}

verifySSL = sinkSettings.getAsBoolean(ConfigConstants.SECURITY_AUDIT_WEBHOOK_SSL_VERIFY, true);
httpClient = getHttpClient();

Expand Down Expand Up @@ -225,6 +242,12 @@ boolean get(AuditMessage msg) {

protected boolean doGet(String url) {
HttpGet httpGet = new HttpGet(url);

// Add Basic Auth header if credentials are configured
if (basicAuthHeader != null) {
httpGet.setHeader("Authorization", basicAuthHeader);
}

CloseableHttpResponse serverResponse = null;
try {
serverResponse = httpClient.execute(httpGet);
Expand Down Expand Up @@ -280,6 +303,11 @@ protected boolean doPost(String url, String payload) {

HttpPost postRequest = new HttpPost(url);

// Add Basic Auth header if credentials are configured
if (basicAuthHeader != null) {
postRequest.setHeader("Authorization", basicAuthHeader);
}

StringEntity input = new StringEntity(payload, webhookFormat.contentType.withCharset(StandardCharsets.UTF_8));
postRequest.setEntity(input);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ public class ConfigConstants {

// External OpenSearch
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_HTTP_ENDPOINTS = "http_endpoints";
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME = "username";
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD = "password";
public static final String SECURITY_AUDIT_CONFIG_USERNAME = "username";
public static final String SECURITY_AUDIT_CONFIG_PASSWORD = "password";
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL = "enable_ssl";
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_VERIFY_HOSTNAMES = "verify_hostnames";
public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL_CLIENT_AUTH = "enable_ssl_client_auth";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.io.HttpRequestHandler;
Expand All @@ -26,12 +29,19 @@ public class TestHttpHandler implements HttpRequestHandler {
public String method;
public String uri;
public String body;
public Map<String, String> headers;

@Override
public void handle(ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context) throws HttpException, IOException {
this.method = request.getMethod();
this.uri = request.getRequestUri();

// Capture headers
this.headers = new HashMap<>();
for (Header header : request.getHeaders()) {
this.headers.put(header.getName(), header.getValue());
}

HttpEntity entity = request.getEntity();
body = EntityUtils.toString(entity, StandardCharsets.UTF_8);
}
Expand All @@ -40,5 +50,6 @@ public void reset() {
this.body = null;
this.uri = null;
this.method = null;
this.headers = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,8 @@ public void testExternalPemUserPass() throws Exception {
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMKEY_FILEPATH,
FileHelper.getAbsoluteFilePathFromClassPath("auditlog/spock.key.pem")
)
.put(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME,
"admin"
)
.put(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD,
"admin"
)
.put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME, "admin")
.put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD, "admin")
.build();

setup(additionalSettings);
Expand Down Expand Up @@ -174,14 +168,8 @@ public void testExternalPemUserPassTp() throws Exception {
+ ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMTRUSTEDCAS_FILEPATH,
FileHelper.getAbsoluteFilePathFromClassPath("auditlog/chain-ca.pem")
)
.put(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME,
"admin"
)
.put(
ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD,
"admin"
)
.put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME, "admin")
.put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD, "admin")
.build();

setup(additionalSettings);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,162 @@ private SSLContext createSSLContext() throws Exception {
return sslContext;
}

@Test
public void basicAuthPostTest() throws Exception {
TestHttpHandler handler = new TestHttpHandler();

int port = findFreePort();
server = ServerBootstrap.bootstrap()
.setListenerPort(port)
.setHttpProcessor(HttpProcessors.server("Test/1.1"))
.setRequestRouter((request, context) -> handler)
.create();

server.start();

String url = "http://localhost:" + port + "/endpoint";
String username = "test_user";
String password = "test_password";

// Test with basic auth credentials - POST JSON
Settings settings = Settings.builder()
.put("plugins.security.audit.config.webhook.url", url)
.put("plugins.security.audit.config.webhook.format", "json")
.put("plugins.security.audit.config.username", username)
.put("plugins.security.audit.config.password", password)
.put("path.home", ".")
.put(
SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH,
FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks")
)
.build();

LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null);
WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback);
AuditMessage msg = MockAuditMessageFactory.validAuditMessage();
auditlog.store(msg);

// Verify request was made
assertThat(handler.method, is("POST"));
Assert.assertNotNull(handler.body);
Assert.assertTrue(handler.body.contains("{"));
assertStringContainsAllKeysAndValues(handler.body);

// Verify Authorization header is present and correct
Assert.assertNotNull(handler.headers);
Assert.assertTrue(handler.headers.containsKey("Authorization"));
String authHeader = handler.headers.get("Authorization");
Assert.assertTrue(authHeader.startsWith("Basic "));

// Decode and verify credentials
String encodedCredentials = authHeader.substring("Basic ".length());
String decodedCredentials = new String(java.util.Base64.getDecoder().decode(encodedCredentials), StandardCharsets.UTF_8);
Assert.assertEquals(username + ":" + password, decodedCredentials);

// no message stored on fallback
assertThat(fallback.messages.size(), is(0));
auditlog.close();
server.awaitTermination(TimeValue.ofSeconds(3));
}

@Test
public void basicAuthGetTest() throws Exception {
TestHttpHandler handler = new TestHttpHandler();

int port = findFreePort();
server = ServerBootstrap.bootstrap()
.setListenerPort(port)
.setHttpProcessor(HttpProcessors.server("Test/1.1"))
.setRequestRouter((request, context) -> handler)
.create();

server.start();

String url = "http://localhost:" + port + "/endpoint";
String username = "test_user";
String password = "test_password";

// Test with basic auth credentials - GET
Settings settings = Settings.builder()
.put("plugins.security.audit.config.webhook.url", url)
.put("plugins.security.audit.config.webhook.format", "URL_PARAMETER_GET")
.put("plugins.security.audit.config.username", username)
.put("plugins.security.audit.config.password", password)
.put("path.home", ".")
.put(
SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH,
FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks")
)
.build();

LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null);
WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback);
AuditMessage msg = MockAuditMessageFactory.validAuditMessage();
auditlog.store(msg);

// Verify request was made with GET method
assertThat(handler.method, is("GET"));

// Verify Authorization header is present and correct
Assert.assertNotNull(handler.headers);
Assert.assertTrue(handler.headers.containsKey("Authorization"));
String authHeader = handler.headers.get("Authorization");
Assert.assertTrue(authHeader.startsWith("Basic "));

// Decode and verify credentials
String encodedCredentials = authHeader.substring("Basic ".length());
String decodedCredentials = new String(java.util.Base64.getDecoder().decode(encodedCredentials), StandardCharsets.UTF_8);
Assert.assertEquals(username + ":" + password, decodedCredentials);

auditlog.close();
server.awaitTermination(TimeValue.ofSeconds(3));
}

@Test
public void webhookWithoutAuthTest() throws Exception {
TestHttpHandler handler = new TestHttpHandler();

int port = findFreePort();
server = ServerBootstrap.bootstrap()
.setListenerPort(port)
.setHttpProcessor(HttpProcessors.server("Test/1.1"))
.setRequestRouter((request, context) -> handler)
.create();

server.start();

String url = "http://localhost:" + port + "/endpoint";

// Test without credentials - should not have Authorization header
Settings settings = Settings.builder()
.put("plugins.security.audit.config.webhook.url", url)
.put("plugins.security.audit.config.webhook.format", "json")
.put("path.home", ".")
.put(
SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH,
FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks")
)
.build();

LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null);
WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback);
AuditMessage msg = MockAuditMessageFactory.validAuditMessage();
auditlog.store(msg);

// Verify request was made
assertThat(handler.method, is("POST"));
Assert.assertNotNull(handler.body);

// Verify Authorization header is NOT present
Assert.assertFalse(handler.headers.containsKey("Authorization"));

// no message stored on fallback
assertThat(fallback.messages.size(), is(0));

auditlog.close();
server.awaitTermination(TimeValue.ofSeconds(3));
}

private void assertStringContainsAllKeysAndValues(String in) {
Assert.assertTrue(in, in.contains(AuditMessage.FORMAT_VERSION));
Assert.assertTrue(in, in.contains(AuditMessage.CATEGORY));
Expand Down
Loading