Skip to content

HPCC-35300 LDAP should handle brute force password hack attempts#21172

Open
kenrowland wants to merge 2 commits intohpcc-systems:candidate-10.2.xfrom
kenrowland:HPCC-35300
Open

HPCC-35300 LDAP should handle brute force password hack attempts#21172
kenrowland wants to merge 2 commits intohpcc-systems:candidate-10.2.xfrom
kenrowland:HPCC-35300

Conversation

@kenrowland
Copy link
Copy Markdown
Contributor

@kenrowland kenrowland commented Apr 2, 2026

Added cache of failed user authentication attempts with configurable number of allowed failed attempts and lockout timeout

Signed-Off-By: Kenneth Rowland kenneth.rowland@lexisnexisrisk.com

Type of change:

  • This change is a bug fix (non-breaking change which fixes an issue).
  • This change is a new feature (non-breaking change which adds functionality).
  • This change improves the code (refactor or other change that does not change the functionality)
  • This change fixes warnings (the fix does not alter the functionality or the generated code)
  • This change is a breaking change (fix or feature that will cause existing behavior to change).
  • This change alters the query API (existing queries will have to be recompiled)

Checklist:

  • My code follows the code style of this project.
    • My code does not create any new warnings from compiler, build system, or lint.
  • The commit message is properly formatted and free of typos.
    • The commit message title makes sense in a changelog, by itself.
    • The commit is signed.
  • My change requires a change to the documentation.
    • I have updated the documentation accordingly, or...
    • I have created a JIRA ticket to update the documentation.
    • Any new interfaces or exported functions are appropriately commented.
  • I have read the CONTRIBUTORS document.
  • The change has been fully tested:
    • I have added tests to cover my changes.
    • All new and existing tests passed.
    • I have checked that this change does not introduce memory leaks.
    • I have used Valgrind or similar tools to check for potential issues.
  • I have given due consideration to all of the following potential concerns:
    • Scalability
    • Performance
    • Security
    • Thread-safety
    • Cloud-compatibility
    • Premature optimization
    • Existing deployed queries will not be broken
    • This change fixes the problem, not just the symptom
    • The target branch of this pull request is appropriate for such a change.
  • There are no similar instances of the same problem that should be addressed
    • I have addressed them here
    • I have raised JIRA issues to address them separately
  • This is a user interface / front-end modification
    • I have tested my changes in multiple modern browsers
    • The component(s) render as expected

Smoketest:

  • Send notifications about my Pull Request position in Smoketest queue.
  • Test my draft Pull Request.

Testing:

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

🔄 Upmerge Test Results

Status: ✅ All branches merged successfully
PR: #21172 - HPCC-35300 LDAP should handle brute force password hack attempts
Base Branch: candidate-10.2.x
Test Time: 2026-04-02 19:49:42 UTC

✅ Successful Branches (1)

  • master
    This comment was automatically generated by the upmerge test workflow.

Added cache of failed user authentication attempts with configurable
number of allowed failed attempts and lockout timeout

Signed-Off-By: Kenneth Rowland kenneth.rowland@lexisnexisrisk.com
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

🔄 Upmerge Test Results

Status: ✅ All branches merged successfully
PR: #21172 - HPCC-35300 LDAP should handle brute force password hack attempts
Base Branch: candidate-10.2.x
Test Time: 2026-04-02 20:35:35 UTC

✅ Successful Branches (1)

  • master
    This comment was automatically generated by the upmerge test workflow.

@kenrowland kenrowland requested a review from asselitx April 3, 2026 13:11
user->setAuthenticateStatus(AS_AUTHENTICATED);

if (AS_AUTHENTICATED == user->getAuthenticateStatus())
if (isCaching)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Shouldn't this entire if (isCaching) still be inside a check to confirm if (AS_AUTHENTICATED == user->getAuthenticateStatus())? Otherwise, as it is now you could be adding a user to the cache if the call m_ldap_client->authenticate(*user) does not authenticate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like an artifact of some Copilot changes. Good find. Updated.

// This design prevents permanent blocking and allows recovery if the underlying condition is resolved.
bool isExpired(const FailedAuthEntry &entry, unsigned currentTick) const
{
const unsigned elapsedMs = currentTick - entry.firstFailureTick; // unsigned wrap is intentional
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and OK because worst it would do is lockout someone for a bit longer than otherwise?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated comment

{
if (m_cacheTimeoutSeconds >= (std::numeric_limits<unsigned>::max() / 1000U))
return std::numeric_limits<unsigned>::max();
return m_cacheTimeoutSeconds * 1000U;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be pedantic, but the setter in combination with this function does have a chance for a race condition to result in a value being set that would return something out of bounds. Maybe use the critical section or have some validation in the setter to keep it in bounds? This is the only thing I saw that probably really warrants a change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with clamping the value.

m_cache.begin(),
m_cache.end(),
[currentTick](const auto& a, const auto& b) {
const unsigned ageA = currentTick - a.second.firstFailureTick;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a possible simplification, but not a requested change. Since msTick is monotonically increasing (until wraparound) couldn't you just compare the firstFailureTick times to get a relative age ordering (smallest value meaning oldest, ignoring error due to wraparound which I can't think of a way to compensate for anyhow). The math with currentTick gives an absolute age, but you still would have error when the two operands are on either side of a wraparound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. We should keep subtraction here: unsigned tick subtraction is the correct wrap-safe pattern for monotonic msTick counters. Directly comparing firstFailureTick/currentTick would be less reliable across counter rollover.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 6, 2026

🔄 Upmerge Test Results

Status: ✅ All branches merged successfully
PR: #21172 - HPCC-35300 LDAP should handle brute force password hack attempts
Base Branch: candidate-10.2.x
Test Time: 2026-04-06 14:09:27 UTC

✅ Successful Branches (1)

  • master
    This comment was automatically generated by the upmerge test workflow.

@kenrowland kenrowland requested a review from asselitx April 6, 2026 14:11
@kenrowland
Copy link
Copy Markdown
Contributor Author

@ghalliday please merge

Copy link
Copy Markdown
Member

@ghalliday ghalliday left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kenrowland some questions about structure and efficiency. I'm not a fan of header only classes, but this is only used from 1 place .
Actually it isn't - thee are many #includes of ldapsecurity.ipp - including from some headers. It is worth minimizing the impact.

unsigned maxAllowedEntries = defaultMaxAllowedEntries)
: m_maxFailedAttempts(maxFailedAttempts),
// Clamp once at construction so ms conversion cannot overflow.
m_cacheTimeoutSeconds(std::min(cacheTimeoutSeconds, std::numeric_limits<unsigned>::max() / 1000U)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment to the other PR - use a much lower threshold than 49 days, otherwise it will never expire.

// Entry has expired; remove it and allow retries. No need to trim the
// full cache here — trimming happens on every insert/update in updateUserLockoutStatus.
m_cache.erase(it);
trimFailedAuthCache();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this call necessary?

if (it != m_cache.end())
{
m_cache.erase(it);
trimFailedAuthCache();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this call here?

void setCacheTimeoutSeconds(unsigned v) { m_cacheTimeoutSeconds = std::min(v, std::numeric_limits<unsigned>::max() / 1000U); }
void setMaxAllowedEntries(unsigned v) { m_maxAllowedEntries = v; }

unsigned getMaxFailedAttempts() const { return m_maxFailedAttempts; }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these needed - they're not called.


void trimFailedAuthCache()
{
// Expect lock is held by caller.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be simplified

  1. since it is called whenever an entry is added it can only have 1 extra entry - that can be checked by an assert.
  2. When checking for expired entries, it could also keep a pointer to the oldest non-expired. That could then be removed without a subsequent re-iteration.

return false;
}

bool rc = doUserAuthenticate(user);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the authentication failed because the server was unavailable?


// Initialize/update the shared failed-auth cache with configured values
{
CriticalBlock block(CLdapSecManager::s_failedAuthCacheInitLock);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is breaking the encapsulation. There should probably be a single set function that internally locks the critical section.

void setMaxFailedAttempts(unsigned v) { m_maxFailedAttempts = v; }
// Keep timeout-ms conversion safe even if set at runtime.
void setCacheTimeoutSeconds(unsigned v) { m_cacheTimeoutSeconds = std::min(v, std::numeric_limits<unsigned>::max() / 1000U); }
void setMaxAllowedEntries(unsigned v) { m_maxAllowedEntries = v; }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theoretically this function and timeout should call trimFailedAuthCache(). This could break my assumption that there is at most one extra. I would resolve by clearing all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants