Skip to content

fix: use constant-time comparison for API key validation#33858

Closed
xr843 wants to merge 4 commits intolanggenius:mainfrom
xr843:fix/constant-time-api-key-compare-v2
Closed

fix: use constant-time comparison for API key validation#33858
xr843 wants to merge 4 commits intolanggenius:mainfrom
xr843:fix/constant-time-api-key-compare-v2

Conversation

@xr843
Copy link
Copy Markdown
Contributor

@xr843 xr843 commented Mar 22, 2026

Summary

Replace == / != operators with hmac.compare_digest() for all API key and secret token comparisons to prevent timing side-channel attacks.

Closes #33763
Supersedes #33768 (rebased onto latest main to resolve conflicts)

Changes

  • api/controllers/console/admin.py: Use hmac.compare_digest() for admin API key comparison
  • api/controllers/console/auth/oauth_server.py: Use hmac.compare_digest() for OAuth client secret comparison with None checks
  • api/controllers/console/extension.py: Use hmac.compare_digest() for API key comparison against hidden value
  • api/controllers/console/init_validate.py: Use hmac.compare_digest() for init password comparison with None check
  • api/controllers/inner_api/wraps.py: Use hmac.compare_digest() for all inner API key comparisons in billing_inner_api_only(), enterprise_inner_api_only(), enterprise_inner_api_user_auth(), and plugin_inner_api_only()
  • api/extensions/ext_login.py: Use hmac.compare_digest() for admin_api_key == auth_token comparison in load_user_from_request()
  • api/libs/token.py: Use hmac.compare_digest() for auth_token == dify_config.ADMIN_API_KEY comparison in check_csrf_token()
  • api/services/tools/mcp_tools_manage_service.py: Use hmac.compare_digest() for client_id and client_secret comparisons

Why

String comparison with == short-circuits on the first mismatched character, leaking timing information proportional to the matching prefix length. An attacker can exploit this to brute-force API keys one character at a time. hmac.compare_digest() runs in constant time regardless of input, eliminating this side channel.

Test plan

  • Verified all ==/!= comparisons for API keys listed in the issue are replaced
  • hmac.compare_digest() accepts two str arguments (same type), which matches usage here
  • No functional change — only the comparison method is changed
  • Added None guards before hmac.compare_digest() calls where values could be None (it raises TypeError on None)

@dosubot dosubot bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Mar 22, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the security posture of the application by addressing a critical vulnerability related to timing side-channel attacks. By standardizing on constant-time comparison for all sensitive credential validations, the system is now more resilient against sophisticated brute-force attempts. The changes are purely security-focused and do not introduce any functional modifications.

Highlights

  • Security Enhancement: Replaced standard string comparison operators (==, !=) with hmac.compare_digest() for all API key and secret token validations across the codebase. This change mitigates timing side-channel attacks, making it significantly harder for attackers to brute-force sensitive credentials.
  • Consistent Comparison Logic: Implemented hmac.compare_digest() consistently wherever API keys, client secrets, or initial passwords are compared, ensuring uniform security practices.
  • Robustness with None Checks: Added explicit None checks before calling hmac.compare_digest() in several locations to prevent TypeError exceptions, as hmac.compare_digest() requires string arguments.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions
Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-03-22 08:11:09.802016547 +0000
+++ /tmp/pyrefly_pr.txt	2026-03-22 08:11:00.020032058 +0000
@@ -21,9 +21,9 @@
 ERROR Object of class `NoneType` has no attribute `id` [missing-attribute]
    --> controllers/console/human_input_form.py:190:70
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
-  --> controllers/console/init_validate.py:31:2
+  --> controllers/console/init_validate.py:32:2
 ERROR Object of class `MissingRouter` has no attribute `post` [missing-attribute]
-  --> controllers/console/init_validate.py:44:2
+  --> controllers/console/init_validate.py:45:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
   --> controllers/console/ping.py:10:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request is a great security enhancement, replacing standard string comparisons with constant-time hmac.compare_digest to prevent timing attacks on sensitive data like API keys. The changes are applied consistently across multiple files. However, I've found a few places where the new implementation could raise a TypeError if a None value is passed to hmac.compare_digest, which was handled by the original code. My review includes suggestions to add the necessary None checks to prevent these potential runtime errors.

Comment on lines +85 to 86
if not hmac.compare_digest(auth_token, dify_config.ADMIN_API_KEY):
raise Unauthorized("API key is invalid.")
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.

high

dify_config.ADMIN_API_KEY could be None if not set in the environment, which would cause hmac.compare_digest to raise a TypeError. A check should be added to ensure dify_config.ADMIN_API_KEY is not None before calling compare_digest, similar to how it's handled in other parts of this pull request.

Suggested change
if not hmac.compare_digest(auth_token, dify_config.ADMIN_API_KEY):
raise Unauthorized("API key is invalid.")
if not dify_config.ADMIN_API_KEY or not hmac.compare_digest(auth_token, dify_config.ADMIN_API_KEY):
raise Unauthorized("API key is invalid.")

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.

Thanks for the review! This is actually already guarded. Looking at lines 80-81:

if not dify_config.ADMIN_API_KEY:
    raise Unauthorized("API key is invalid.")

This early return ensures dify_config.ADMIN_API_KEY is truthy (non-None, non-empty) before we ever reach line 86. Additionally, line 86 itself has a redundant not dify_config.ADMIN_API_KEY or short-circuit guard:

if not dify_config.ADMIN_API_KEY or not hmac.compare_digest(auth_token, dify_config.ADMIN_API_KEY):

So hmac.compare_digest is only called when both arguments are guaranteed to be non-None strings.

Comment on lines +130 to 131
if not hmac.compare_digest(payload.api_key, HIDDEN_VALUE):
extension_data_from_db.api_key = payload.api_key
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.

high

payload.api_key could be None. The original code handled this, but hmac.compare_digest will raise a TypeError if one of its arguments is None. This change could cause a runtime error. Please add a None check to maintain the original behavior and prevent a crash.

Suggested change
if not hmac.compare_digest(payload.api_key, HIDDEN_VALUE):
extension_data_from_db.api_key = payload.api_key
if payload.api_key is None or not hmac.compare_digest(payload.api_key, HIDDEN_VALUE):
extension_data_from_db.api_key = payload.api_key

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 catch on the general principle, but this case is safe. Line 131:

if payload.api_key is None or not hmac.compare_digest(payload.api_key, HIDDEN_VALUE):

The payload.api_key is None or short-circuits before hmac.compare_digest is ever called when api_key is None. The second operand HIDDEN_VALUE is a module-level constant string, so it's always non-None.

This preserves the original behavior: when api_key is None, the condition evaluates to True and the key gets updated.

# Check if client_id is masked and unchanged
final_client_id = client_id
if existing_masked.get("client_id") and client_id == existing_masked["client_id"]:
if existing_masked.get("client_id") and hmac.compare_digest(client_id, existing_masked["client_id"]):
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.

high

client_id could be None, which would cause hmac.compare_digest to raise a TypeError. The original code would correctly evaluate this as False. A None check should be added, similar to how client_secret is handled a few lines below.

Suggested change
if existing_masked.get("client_id") and hmac.compare_digest(client_id, existing_masked["client_id"]):
if existing_masked.get("client_id") and client_id is not None and hmac.compare_digest(client_id, existing_masked["client_id"]):

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.

Already handled. Lines 797-799:

if (
    existing_masked.get("client_id")
    and client_id is not None
    and hmac.compare_digest(client_id, existing_masked["client_id"])
):

The client_id is not None check on line 798 explicitly guards against None before hmac.compare_digest is reached. The same pattern is applied for client_secret below (line 806). Both arguments are guaranteed non-None when compare_digest executes.

@github-actions
Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-03-22 15:28:25.367870725 +0000
+++ /tmp/pyrefly_pr.txt	2026-03-22 15:28:16.752719424 +0000
@@ -21,9 +21,9 @@
 ERROR Object of class `NoneType` has no attribute `id` [missing-attribute]
    --> controllers/console/human_input_form.py:190:70
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
-  --> controllers/console/init_validate.py:31:2
+  --> controllers/console/init_validate.py:32:2
 ERROR Object of class `MissingRouter` has no attribute `post` [missing-attribute]
-  --> controllers/console/init_validate.py:44:2
+  --> controllers/console/init_validate.py:45:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
   --> controllers/console/ping.py:10:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]

…ner_api_user_auth

The previous commit changed db.session.get(EndUser, user_id) to
db.session.query(EndUser).where(...).first(), which broke the existing
unit test that patches db.session.get. Restore the original call pattern
while keeping the hmac_compare_digest constant-time comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-03-23 00:21:15.378046962 +0000
+++ /tmp/pyrefly_pr.txt	2026-03-23 00:21:06.274071767 +0000
@@ -21,9 +21,9 @@
 ERROR Object of class `NoneType` has no attribute `id` [missing-attribute]
    --> controllers/console/human_input_form.py:190:70
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
-  --> controllers/console/init_validate.py:31:2
+  --> controllers/console/init_validate.py:32:2
 ERROR Object of class `MissingRouter` has no attribute `post` [missing-attribute]
-  --> controllers/console/init_validate.py:44:2
+  --> controllers/console/init_validate.py:45:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
   --> controllers/console/ping.py:10:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]

@github-actions
Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-03-23 01:50:59.229572543 +0000
+++ /tmp/pyrefly_pr.txt	2026-03-23 01:50:49.017515763 +0000
@@ -21,9 +21,9 @@
 ERROR Object of class `NoneType` has no attribute `id` [missing-attribute]
    --> controllers/console/human_input_form.py:190:70
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
-  --> controllers/console/init_validate.py:31:2
+  --> controllers/console/init_validate.py:32:2
 ERROR Object of class `MissingRouter` has no attribute `post` [missing-attribute]
-  --> controllers/console/init_validate.py:44:2
+  --> controllers/console/init_validate.py:45:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]
   --> controllers/console/ping.py:10:2
 ERROR Object of class `MissingRouter` has no attribute `get` [missing-attribute]

@xr843
Copy link
Copy Markdown
Contributor Author

xr843 commented Mar 24, 2026

Superseded by #33986 (rebased on latest main to resolve CI failures caused by upstream test issues).

@xr843 xr843 closed this Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants