Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,7 @@ Before merging this dependent PR, merge networknt/light-aws-lambda#168. Resolve
Fetch each sibling repository’s master and verify the selected commit is its ancestor with `git merge-base --is-ancestor <merge-sha> origin/master`. Replace the corresponding values in `ci/jackson3-source-refs.json` with those full 40-character merge SHAs in a reviewed dependent change. Keep the remaining closure pins and the qualified core/policy SHAs unchanged. Run fresh source-build CI on that dependent commit and review its resolved-source artifact before merging. A squash or merge commit must be qualified even when its runtime tree matches the earlier pin.

For later sibling updates after this PR merges, use the same manifest-update PR and fresh dependent CI procedure. Never silently advance pins or infer qualification from the sibling’s own green build.

## Basic Auth credential hardening

See [A20/A25 assessment and credential ownership](doc/security/basic-auth-credentials.md) for the scoped Basic Auth hardening and remaining memory/logging limits.
56 changes: 56 additions & 0 deletions doc/security/basic-auth-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Basic Auth credential handling (A20 / A25)

[Issue #191](https://github.com/networknt/light-lambda-native/issues/191) covers
partner Checkmarx A25 rows 90–97 and A20 row 98.

## Re-evaluation

At master `b6d27a7d42ecdc94efe387acdd1cb4b3f47a7ce7` and 2.x
`785235d5324697958a573cfa6ad6e664615e19ab`, the reported A25 password/header
logging paths were already mitigated: authentication error messages and LDAP
dispatch traces omit submitted credentials. The scoped fix therefore addresses
A20, with regression coverage protecting A25. Denied-path diagnostics still
include the configured username and sanitized path; this is not a promise to
remove all identity data from logs.

## Ownership and cleanup

Local Basic authentication no longer creates an immutable decoded credential
pair or password String. A request-local closeable owns the decoded bytes and
password bytes and clears them after authentication/authorization, including
early returns and exceptions. The shared scheme check scans characters without creating a credential substring.
Temporary header/payload copies and configured
password comparison bytes are also cleared. Local comparison continues to use
`MessageDigest.isEqual`. Existing raw `username:password` compatibility, Base64
decoding, Unicode passwords, colons in passwords, status codes, reserved-user
rules and LDAP dispatch are retained. The scheme is removed at a character
boundary, preserving the existing case-insensitive Unicode scheme matching.
Malformed UTF-8 in Base64-decoded credentials retains the historical UTF-8
replacement behavior for local comparison and LDAP; normalization uses temporary
character/byte buffers cleared after use, without an immutable credential String. Conversion uses explicit encoder/decoder
output buffers sized from their advertised maximum expansion, rather than the
convenience charset methods that can resize and discard an earlier buffer.
Every supplied conversion array is cleared in a finally block, including unused
capacity; returned byte copies remain owned by their existing cleanup scopes.

The protected `handleLdapAuth(UserAuth, String)` extension and the LDAP library
still require a String. Only that branch constructs a password String; it cannot
be zeroed. No borrowed request header or configured password is modified.
The Lambda request, configuration, decoder/JVM internals and downstream LDAP
implementation may retain secrets. Clearing owned arrays reduces extra retention
and does not guarantee complete heap erasure or establish a remote exploit.
Protect request/access/audit logs, memory dumps, diagnostics and transport.

## Verification boundaries

Focused JVM tests exercise the existing Basic authentication contract, owned
buffer cleanup for successful/malformed/empty/exceptional scopes and through the
real middleware's return and LDAP-exception paths, and synthetic
local/LDAP outcomes at TRACE. Log checks inspect messages, argument arrays, MDC
and throwable causes/suppressed exceptions; returned status content is checked
for canaries as well. Distinct password prefix/suffix fragments and the Unicode
suffix are checked in addition to full encoded credentials. Negative controls
verify these checks catch partial logging and omitted middleware cleanup.
LDAP is a local test double. No live LDAP bind, AWS
deployment, native-image rebuild or partner rescan is implied by these tests.
Deployment requires rebuilding and distributing the native Lambda artifact.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ static AuthorizationScheme parse(String header) {
}

static boolean hasCredentials(String header, AuthorizationScheme scheme) {
return scheme != UNKNOWN && parse(header) == scheme && header.length() > scheme.name().length() + 1
&& !header.substring(scheme.name().length() + 1).trim().isEmpty();
if (scheme == UNKNOWN || parse(header) != scheme) return false;
return hasTrimmedContent(header, scheme.name().length() + 1);
}

// Match String.trim() semantics without copying credential material into another String.
static boolean hasTrimmedContent(String header, int start) {
for (int i = start; i < header.length(); i++) {
if (header.charAt(i) > ' ') return true;
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.networknt.aws.lambda.handler.middleware.security;

import org.apache.commons.codec.binary.Base64;

import java.security.MessageDigest;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.util.Arrays;

import static java.nio.charset.StandardCharsets.UTF_8;

/** Request-local, owned copies only; the original request and configuration remain borrowed. */
final class BasicAuthCredentials implements AutoCloseable {
private final byte[] decoded;
private final byte[] password;
private final int separator;

private BasicAuthCredentials(byte[] decoded) {
this.decoded = decoded;
separator = separator(decoded);
password = separator < 0 ? new byte[0] : Arrays.copyOfRange(decoded, separator + 1, decoded.length);
}

static BasicAuthCredentials parse(String auth) {
byte[] payload = encodeUtf8(CharBuffer.wrap(auth, 6, auth.length()));
byte[] decoded = null;
try {
// Preserve the existing raw username:password compatibility and lenient Base64 decoder.
if (separator(payload) >= 0) {
decoded = payload;
} else {
decoded = Base64.decodeBase64(payload);
byte[] normalized = normalizeUtf8(decoded);
Arrays.fill(decoded, (byte) 0);
decoded = normalized;
}
BasicAuthCredentials credentials = new BasicAuthCredentials(decoded);
if (decoded == payload) payload = null;
decoded = null; // Ownership moves to the closeable credentials.
return credentials;
} finally {
if (payload != null) Arrays.fill(payload, (byte) 0);
if (decoded != null) Arrays.fill(decoded, (byte) 0);
}
}

// Caller-supplied output prevents convenience charset APIs from discarding grown buffers.
private static byte[] encodeUtf8(CharBuffer characters) {
var encoder = UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
int capacity = Math.multiplyExact(characters.remaining(), (int) Math.ceil(encoder.maxBytesPerChar()));
byte[] bytes = new byte[capacity];
try {
ByteBuffer output = ByteBuffer.wrap(bytes);
requireUnderflow(encoder.encode(characters, output, true));
requireUnderflow(encoder.flush(output));
return Arrays.copyOf(bytes, output.position());
} finally {
Arrays.fill(bytes, (byte) 0);
}
}

// Preserve the former new String(decoded, UTF_8) replacement semantics without a String.
private static byte[] normalizeUtf8(byte[] decoded) {
var decoder = UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
int capacity = Math.multiplyExact(decoded.length, (int) Math.ceil(decoder.maxCharsPerByte()));
char[] characters = new char[capacity];
try {
CharBuffer output = CharBuffer.wrap(characters);
requireUnderflow(decoder.decode(ByteBuffer.wrap(decoded), output, true));
requireUnderflow(decoder.flush(output));
output.flip();
return encodeUtf8(output);
} finally {
Arrays.fill(characters, '\0');
}
}

private static void requireUnderflow(CoderResult result) {
if (!result.isUnderflow()) throw new IllegalStateException("Unexpected UTF-8 conversion result");
}

private static int separator(byte[] bytes) {
for (int i = 0; i < bytes.length; i++) if (bytes[i] == ':') return i;
return -1;
}

boolean hasSeparator() { return separator >= 0; }
boolean isPasswordEmpty() { return password.length == 0; }
String username() { return new String(decoded, 0, separator, UTF_8); }

boolean matches(String configuredPassword) {
if (configuredPassword == null) return false;
byte[] expected = encodeUtf8(CharBuffer.wrap(configuredPassword));
try {
return MessageDigest.isEqual(password, expected);
} finally {
Arrays.fill(expected, (byte) 0);
}
}

// The existing LDAP API/extension requires a String; only this branch creates it.
String ldapPassword() { return new String(password, UTF_8); }

@Override
public void close() {
Arrays.fill(decoded, (byte) 0);
Arrays.fill(password, (byte) 0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.security.MessageDigest;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;

import static java.nio.charset.StandardCharsets.UTF_8;

public class BasicAuthMiddleware implements MiddlewareHandler {
private static final Logger LOG = LoggerFactory.getLogger(BasicAuthMiddleware.class);
static final String BEARER_PREFIX = "BEARER";
Expand Down Expand Up @@ -63,7 +60,7 @@ public Status execute(LightLambdaExchange exchange) {
} else {
/* contains auth header */
String auth = optionalAuth.get();
if(auth.trim().isEmpty()) {
if (!AuthorizationScheme.hasTrimmedContent(auth, 0)) {
return this.handleAnonymousAuth(exchange, requestPath, config);
}
AuthorizationScheme scheme = AuthorizationScheme.parse(auth);
Expand Down Expand Up @@ -148,18 +145,15 @@ public Status handleBasicAuth(LightLambdaExchange exchange, String requestPath,
LOG.error("Invalid/Unsupported Basic authorization header.");
return new Status(INVALID_AUTHORIZATION_HEADER, BASIC_PREFIX);
}
String credentials = auth.substring(6);
int pos = credentials.indexOf(':');
if (pos == -1) {
credentials = new String(org.apache.commons.codec.binary.Base64.decodeBase64(credentials), UTF_8);
}
pos = credentials.indexOf(':');
if (pos != -1) {
String username = credentials.substring(0, pos);
String password = credentials.substring(pos + 1);
try (BasicAuthCredentials credentials = parseCredentials(auth)) {
if (!credentials.hasSeparator()) {
LOG.error("Invalid basic authentication header. It must be username:password base64 encode.");
return new Status(INVALID_BASIC_HEADER, BASIC_PREFIX);
}
String username = credentials.username();
UserAuth user = config.getUsers().get(username);
// Reserved users describe authorization policies, never Basic identities.
if (user == null || StringUtils.isEmpty(password)
if (user == null || credentials.isPasswordEmpty()
|| BasicAuthConfig.ANONYMOUS.equals(username) || BasicAuthConfig.BEARER.equals(username)) {
LOG.error("Invalid Basic username or password.");
if(LOG.isDebugEnabled()) LOG.debug("BasicAuthMiddleware.execute ends with an error.");
Expand All @@ -168,14 +162,13 @@ public Status handleBasicAuth(LightLambdaExchange exchange, String requestPath,
if (StringUtils.isEmpty(user.getPassword()) && config.isEnableAD()) {
// Delegate configured identities without a local password to LDAP.
if(LOG.isTraceEnabled()) LOG.trace("Call LdapUtil for Basic authentication.");
if (!handleLdapAuth(user, password)) {
if (!handleLdapAuth(user, credentials.ldapPassword())) {
if(LOG.isDebugEnabled()) LOG.debug("BasicAuthMiddleware.execute ends with an error.");
return new Status(INVALID_USERNAME_OR_PASSWORD);
}
} else {
// Compare the configured local password without early character mismatches.
if (!(user.getPassword() != null
&& MessageDigest.isEqual(password.getBytes(UTF_8), user.getPassword().getBytes(UTF_8)))) {
if (!credentials.matches(user.getPassword())) {
LOG.error("Invalid Basic username or password.");
if (LOG.isDebugEnabled()) LOG.debug("BasicAuthMiddleware.execute ends with an error.");
return new Status(INVALID_USERNAME_OR_PASSWORD);
Expand All @@ -196,14 +189,15 @@ public Status handleBasicAuth(LightLambdaExchange exchange, String requestPath,
if(LOG.isDebugEnabled()) LOG.debug("BasicAuthMiddleware.execute ends with an error.");
return new Status(NOT_AUTHORIZED_REQUEST_PATH, safePath, user.getUsername());
}
} else {
LOG.error("Invalid basic authentication header. It must be username:password base64 encode.");
if(LOG.isDebugEnabled()) LOG.debug("BasicAuthMiddleware.execute ends with an error.");
return new Status(INVALID_BASIC_HEADER, BASIC_PREFIX);
}
return successMiddlewareStatus();
}

// Package-local boundary lets lifecycle tests observe buffers used by the real middleware.
BasicAuthCredentials parseCredentials(String auth) {
return BasicAuthCredentials.parse(auth);
}

/**
* Handle LDAP authentication and authorization
* @param user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.networknt.aws.lambda.handler.middleware.security;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class AuthorizationSchemeCredentialsTest {
@Test
void credentialPresencePreservesSchemeAndTrimSemantics() {
for (AuthorizationScheme scheme : new AuthorizationScheme[]{AuthorizationScheme.BASIC, AuthorizationScheme.BEARER}) {
assertFalse(AuthorizationScheme.hasCredentials(null, scheme));
assertFalse(AuthorizationScheme.hasCredentials(scheme.name(), scheme));
assertFalse(AuthorizationScheme.hasCredentials(scheme.name() + "Secret", scheme));
for (char whitespace = 0; whitespace <= ' '; whitespace++) {
String prefix = scheme.name() + " " + whitespace;
assertFalse(AuthorizationScheme.hasCredentials(prefix, scheme));
assertTrue(AuthorizationScheme.hasCredentials(prefix + "credential", scheme));
}
assertTrue(AuthorizationScheme.hasCredentials(scheme.name().toLowerCase(java.util.Locale.ROOT) + " x", scheme));
assertTrue(AuthorizationScheme.hasCredentials(scheme.name() + " \u00a0", scheme));
}
assertFalse(AuthorizationScheme.hasCredentials("Basic x", AuthorizationScheme.BEARER));
assertFalse(AuthorizationScheme.hasCredentials("Basic x", AuthorizationScheme.UNKNOWN));
}
}
Loading
Loading