CVE-2025-59036
Description
Infrahub offers a central hub to manage data, templates, and playbooks. Prior to versiond 1.3.9 and 1.4.5, a bug in the authentication logic will cause API tokens that were deleted and/or expired to be considered valid. This means that any API token that is associated with an active user account can authenticate successfully. This issue is fixed in versions 1.3.9 and 1.4.5. As a workaround, users can delete or deactivate the account associated with a deleted API token to prevent that token from authenticating.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
infrahub-serverPyPI | < 1.3.9 | 1.3.9 |
infrahub-serverPyPI | >= 1.4.0, < 1.4.5 | 1.4.5 |
Affected products
1Patches
3215185f217e2prep release 1.3.9 (#7177)
7 files changed · +151 −37
backend/infrahub/core/account.py+60 −34 modified@@ -5,9 +5,10 @@ from typing_extensions import Self -from infrahub.core.constants import InfrahubKind, PermissionDecision +from infrahub.core.constants import NULL_VALUE, InfrahubKind, PermissionDecision from infrahub.core.query import Query, QueryType from infrahub.core.registry import registry +from infrahub.core.timestamp import Timestamp if TYPE_CHECKING: from infrahub.core.branch import Branch @@ -519,47 +520,72 @@ def __init__(self, token: str, **kwargs: Any): super().__init__(**kwargs) async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002 - token_filter_perms, token_params = self.branch.get_query_filter_relationships( - rel_labels=["r1", "r2"], at=self.at, include_outside_parentheses=True + branch_filter, branch_params = self.branch.get_query_filter_path( + at=self.at.to_string(), branch_agnostic=self.branch_agnostic, is_isolated=False ) - self.params.update(token_params) - - account_filter_perms, account_params = self.branch.get_query_filter_relationships( - rel_labels=["r31", "r41", "r5", "r6"], at=self.at, include_outside_parentheses=True + self.params.update(branch_params) + self.params.update( + { + "token_attr_name": "token", + "token_relationship_name": "account__token", + "token_value": self.token, + "null_value": NULL_VALUE, + } ) - self.params.update(account_params) - self.params["token_value"] = self.token - - # ruff: noqa: E501 query = """ - MATCH (at:InternalAccountToken)-[r1:HAS_ATTRIBUTE]-(a:Attribute {name: "token"})-[r2:HAS_VALUE]-(av:AttributeValue { value: $token_value }) - WHERE %s - WITH at - MATCH (at)-[r31]-(:Relationship)-[r41]-(acc:CoreGenericAccount)-[r5:HAS_ATTRIBUTE]-(an:Attribute {name: "name"})-[r6:HAS_VALUE]-(av:AttributeValue) - WHERE %s - """ % ( - "\n AND ".join(token_filter_perms), - "\n AND ".join(account_filter_perms), - ) - +// -------------- +// get the active token node for this token value, if it exists +// -------------- +MATCH (token_node:%(token_node_kind)s)-[r1:HAS_ATTRIBUTE]->(:Attribute {name: $token_attr_name}) + -[r2:HAS_VALUE]->(av:AttributeValue { value: $token_value }) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH token_node +WHERE r1.status = "active" AND r2.status = "active" +// -------------- +// get the expiration time +// -------------- +OPTIONAL MATCH (token_node)-[r1:HAS_ATTRIBUTE]->(:Attribute {name: "expiration"}) + -[r2:HAS_VALUE]->(av) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH token_node, CASE + WHEN r1.status = "active" AND r2.status = "active" AND av.value <> $null_value THEN av.value + ELSE NULL +END AS expiration +// -------------- +// get the linked account node from the token node +// -------------- +MATCH (token_node)-[r1:IS_RELATED]-(:Relationship {name: $token_relationship_name})-[r2:IS_RELATED]-(account_node:%(account_node_kind)s) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH expiration, account_node +WHERE r1.status = "active" AND r2.status = "active" + """ % { + "branch_filter": branch_filter, + "token_node_kind": InfrahubKind.ACCOUNTTOKEN, + "account_node_kind": InfrahubKind.GENERICACCOUNT, + } self.add_to_query(query) - - self.return_labels = ["at", "av", "acc"] - - def get_account_name(self) -> str | None: - """Return the account name that matched the query or None.""" - if result := self.get_result(): - return result.get("av").get("value") - - return None + self.return_labels = ["account_node.uuid AS account_uuid", "expiration"] def get_account_id(self) -> str | None: """Return the account id that matched the query or a None.""" - if result := self.get_result(): - return result.get("acc").get("uuid") - - return None + result = self.get_result() + if not result: + return None + account_uuid = result.get_as_str(label="account_uuid") + expiration_with_tz = result.get_as_str(label="expiration") + if expiration_with_tz is None: + return account_uuid + expiration = Timestamp(expiration_with_tz) + if expiration < Timestamp(): + return None + return account_uuid async def validate_token(token: str, db: InfrahubDatabase, branch: Branch | str | None = None) -> str | None:
backend/tests/unit/core/test_account.py+58 −0 modified@@ -1,9 +1,13 @@ import pytest +from infrahub_sdk.timestamp import Timestamp +from pytz import timezone from infrahub.auth import authenticate_with_password, authentication_token, validate_active_account from infrahub.core import registry from infrahub.core.account import validate_token from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager from infrahub.core.node import Node from infrahub.database import InfrahubDatabase from infrahub.exceptions import AuthorizationError @@ -29,13 +33,67 @@ async def test_validate_token(db: InfrahubDatabase, default_branch, register_cor user1 = await Node.init(db=db, schema=account_schema) await user1.new(db=db, name="user1", password="User1Password123") await user1.save(db=db) + user2 = await Node.init(db=db, schema=account_schema) + await user2.new(db=db, name="user2", password="User2Password234") + await user2.save(db=db) token1 = await Node.init(db=db, schema=account_token_schema) await token1.new(db=db, token="123456789", account=user1) await token1.save(db=db) assert await validate_token(token="123456789", db=db) == user1.id assert await validate_token(token="987654321", db=db) is None + # with future expiration + right_now = Timestamp() + future = right_now.add(minutes=1) + token1.expiration.value = future.to_string() + await token1.save(db=db) + assert await validate_token(token="123456789", db=db) == user1.id + + branch = await create_branch(db=db, branch_name="token_branch") + + # test with updated account + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + await token1.account.update(db=db, data=user2) + await token1.save(db=db) + assert await validate_token(token="123456789", db=db, branch=branch) == user2.id + + # test updated token value + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + token1.token.value = "123454321" + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) == user2.id + assert await validate_token(token="123456789", db=db, branch=branch) is None + + # test updated past expiration + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + past = right_now.add(minutes=-1) + token1.expiration.value = past.to_string() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) is None + + # test updated past expiration with tz + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + past = right_now.add(minutes=-1) + # UTC-7 + past_with_tz = past.to_datetime().astimezone(timezone("US/Pacific")) + token1.expiration.value = past_with_tz.isoformat() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) is None + + # test updated future expiration with tz + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + future = right_now.add(minutes=1) + # UTC+9 + future_with_tz = future.to_datetime().astimezone(timezone("Asia/Tokyo")) + token1.expiration.value = future_with_tz.isoformat() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) == user2.id + + # test delete works + await token1.delete(db=db) + assert await validate_token(token="123454321", db=db) is None + async def test_account_status(db: InfrahubDatabase, default_branch, register_core_models_schema): account_schema = registry.schema.get_node_schema(name=InfrahubKind.ACCOUNT, branch=default_branch)
CHANGELOG.md+6 −0 modified@@ -11,6 +11,12 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang <!-- towncrier release notes start --> +## [Infrahub - v1.3.9](https://github.com/opsmill/infrahub/tree/infrahub-v1.3.9) - 2025-08-26 + +### Security + +- Fixes bug in authentication logic that allowed expired and/or deleted API tokens to authenticate successfully. + ## [Infrahub - v1.3.8](https://github.com/opsmill/infrahub/tree/infrahub-v1.3.8) - 2025-08-26 ### Fixed
docs/docs/release-notes/infrahub/release-1_3_9.mdx+23 −0 added@@ -0,0 +1,23 @@ +--- +title: Release 1.3.9 +--- +<table> + <tbody> + <tr> + <th>Release Number</th> + <td>1.3.9</td> + </tr> + <tr> + <th>Release Date</th> + <td>September 8th, 2025</td> + </tr> + <tr> + <th>Tag</th> + <td>[infrahub-v1.3.9](https://github.com/opsmill/infrahub/releases/tag/infrahub-v1.3.9)</td> + </tr> + </tbody> +</table> + +### Security + +- Fixes bug in authentication logic that allowed expired and/or deleted API tokens to authenticate successfully.
docs/sidebars.ts+1 −0 modified@@ -386,6 +386,7 @@ const sidebars: SidebarsConfig = { slug: 'release-notes/infrahub', }, items: [ + 'release-notes/infrahub/release-1_3_9', 'release-notes/infrahub/release-1_3_7', 'release-notes/infrahub/release-1_3_6', 'release-notes/infrahub/release-1_3_5',
pyproject.toml+1 −1 modified@@ -1,6 +1,6 @@ [tool.poetry] name = "infrahub-server" -version = "1.3.8" +version = "1.3.9" description = "Infrahub is taking a new approach to Infrastructure Management by providing a new generation of datastore to organize and control all the data that defines how an infrastructure should run." authors = ["OpsMill <info@opsmill.com>"] readme = "README.md"
python_testcontainers/pyproject.toml+2 −2 modified@@ -1,11 +1,11 @@ [project] name = "infrahub-testcontainers" -version = "1.3.8" +version = "1.3.9" requires-python = ">=3.9" [tool.poetry] name = "infrahub-testcontainers" -version = "1.3.8" +version = "1.3.9" description = "Testcontainers instance for Infrahub to easily build integration tests" authors = ["OpsMill <info@opsmill.com>"] readme = "README.md"
50928dc3c15echore: update docker-compose
1 file changed · +3 −3
docker-compose.yml+3 −3 modified@@ -199,7 +199,7 @@ services: - 6362:6362 task-manager: - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.4}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.5}" command: uvicorn --host 0.0.0.0 --port 4200 --factory infrahub.prefect_server.app:create_infrahub_prefect restart: unless-stopped depends_on: @@ -232,7 +232,7 @@ services: retries: 5 infrahub-server: - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.4}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.5}" restart: unless-stopped command: > gunicorn --config backend/infrahub/serve/gunicorn_config.py @@ -278,7 +278,7 @@ services: deploy: mode: replicated replicas: 2 - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.4}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.5}" command: prefect worker start --type infrahubasync --pool infrahub-worker --with-healthcheck restart: unless-stopped depends_on:
61b49a4a9e98update AccountTokenValidatorQuery (#7160)
2 files changed · +118 −34
backend/infrahub/core/account.py+60 −34 modified@@ -5,9 +5,10 @@ from typing_extensions import Self -from infrahub.core.constants import InfrahubKind, PermissionDecision +from infrahub.core.constants import NULL_VALUE, InfrahubKind, PermissionDecision from infrahub.core.query import Query, QueryType from infrahub.core.registry import registry +from infrahub.core.timestamp import Timestamp if TYPE_CHECKING: from infrahub.core.branch import Branch @@ -519,47 +520,72 @@ def __init__(self, token: str, **kwargs: Any): super().__init__(**kwargs) async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002 - token_filter_perms, token_params = self.branch.get_query_filter_relationships( - rel_labels=["r1", "r2"], at=self.at, include_outside_parentheses=True + branch_filter, branch_params = self.branch.get_query_filter_path( + at=self.at.to_string(), branch_agnostic=self.branch_agnostic, is_isolated=False ) - self.params.update(token_params) - - account_filter_perms, account_params = self.branch.get_query_filter_relationships( - rel_labels=["r31", "r41", "r5", "r6"], at=self.at, include_outside_parentheses=True + self.params.update(branch_params) + self.params.update( + { + "token_attr_name": "token", + "token_relationship_name": "account__token", + "token_value": self.token, + "null_value": NULL_VALUE, + } ) - self.params.update(account_params) - self.params["token_value"] = self.token - - # ruff: noqa: E501 query = """ - MATCH (at:InternalAccountToken)-[r1:HAS_ATTRIBUTE]-(a:Attribute {name: "token"})-[r2:HAS_VALUE]-(av:AttributeValue { value: $token_value }) - WHERE %s - WITH at - MATCH (at)-[r31]-(:Relationship)-[r41]-(acc:CoreGenericAccount)-[r5:HAS_ATTRIBUTE]-(an:Attribute {name: "name"})-[r6:HAS_VALUE]-(av:AttributeValue) - WHERE %s - """ % ( - "\n AND ".join(token_filter_perms), - "\n AND ".join(account_filter_perms), - ) - +// -------------- +// get the active token node for this token value, if it exists +// -------------- +MATCH (token_node:%(token_node_kind)s)-[r1:HAS_ATTRIBUTE]->(:Attribute {name: $token_attr_name}) + -[r2:HAS_VALUE]->(av:AttributeValueIndexed { value: $token_value }) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH token_node +WHERE r1.status = "active" AND r2.status = "active" +// -------------- +// get the expiration time +// -------------- +OPTIONAL MATCH (token_node)-[r1:HAS_ATTRIBUTE]->(:Attribute {name: "expiration"}) + -[r2:HAS_VALUE]->(av) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH token_node, CASE + WHEN r1.status = "active" AND r2.status = "active" AND av.value <> $null_value THEN av.value + ELSE NULL +END AS expiration +// -------------- +// get the linked account node from the token node +// -------------- +MATCH (token_node)-[r1:IS_RELATED]-(:Relationship {name: $token_relationship_name})-[r2:IS_RELATED]-(account_node:%(account_node_kind)s) +WHERE all(r in [r1, r2] WHERE (%(branch_filter)s)) +ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC, r2.branch_level DESC, r2.from DESC, r2.status ASC +LIMIT 1 +WITH expiration, account_node +WHERE r1.status = "active" AND r2.status = "active" + """ % { + "branch_filter": branch_filter, + "token_node_kind": InfrahubKind.ACCOUNTTOKEN, + "account_node_kind": InfrahubKind.GENERICACCOUNT, + } self.add_to_query(query) - - self.return_labels = ["at", "av", "acc"] - - def get_account_name(self) -> str | None: - """Return the account name that matched the query or None.""" - if result := self.get_result(): - return result.get("av").get("value") - - return None + self.return_labels = ["account_node.uuid AS account_uuid", "expiration"] def get_account_id(self) -> str | None: """Return the account id that matched the query or a None.""" - if result := self.get_result(): - return result.get("acc").get("uuid") - - return None + result = self.get_result() + if not result: + return None + account_uuid = result.get_as_str(label="account_uuid") + expiration_with_tz = result.get_as_str(label="expiration") + if expiration_with_tz is None: + return account_uuid + expiration = Timestamp(expiration_with_tz) + if expiration < Timestamp(): + return None + return account_uuid async def validate_token(token: str, db: InfrahubDatabase, branch: Branch | str | None = None) -> str | None:
backend/tests/unit/core/test_account.py+58 −0 modified@@ -1,9 +1,13 @@ import pytest +from infrahub_sdk.timestamp import Timestamp +from pytz import timezone from infrahub.auth import authenticate_with_password, authentication_token, validate_active_account from infrahub.core import registry from infrahub.core.account import validate_token from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager from infrahub.core.node import Node from infrahub.database import InfrahubDatabase from infrahub.exceptions import AuthorizationError @@ -29,13 +33,67 @@ async def test_validate_token(db: InfrahubDatabase, default_branch, register_cor user1 = await Node.init(db=db, schema=account_schema) await user1.new(db=db, name="user1", password="User1Password123") await user1.save(db=db) + user2 = await Node.init(db=db, schema=account_schema) + await user2.new(db=db, name="user2", password="User2Password234") + await user2.save(db=db) token1 = await Node.init(db=db, schema=account_token_schema) await token1.new(db=db, token="123456789", account=user1) await token1.save(db=db) assert await validate_token(token="123456789", db=db) == user1.id assert await validate_token(token="987654321", db=db) is None + # with future expiration + right_now = Timestamp() + future = right_now.add(minutes=1) + token1.expiration.value = future.to_string() + await token1.save(db=db) + assert await validate_token(token="123456789", db=db) == user1.id + + branch = await create_branch(db=db, branch_name="token_branch") + + # test with updated account + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + await token1.account.update(db=db, data=user2) + await token1.save(db=db) + assert await validate_token(token="123456789", db=db, branch=branch) == user2.id + + # test updated token value + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + token1.token.value = "123454321" + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) == user2.id + assert await validate_token(token="123456789", db=db, branch=branch) is None + + # test updated past expiration + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + past = right_now.add(minutes=-1) + token1.expiration.value = past.to_string() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) is None + + # test updated past expiration with tz + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + past = right_now.add(minutes=-1) + # UTC-7 + past_with_tz = past.to_datetime().astimezone(timezone("US/Pacific")) + token1.expiration.value = past_with_tz.isoformat() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) is None + + # test updated future expiration with tz + token1 = await NodeManager.get_one(db=db, branch=branch, id=token1.id) + future = right_now.add(minutes=1) + # UTC+9 + future_with_tz = future.to_datetime().astimezone(timezone("Asia/Tokyo")) + token1.expiration.value = future_with_tz.isoformat() + await token1.save(db=db) + assert await validate_token(token="123454321", db=db, branch=branch) == user2.id + + # test delete works + await token1.delete(db=db) + assert await validate_token(token="123454321", db=db) is None + async def test_account_status(db: InfrahubDatabase, default_branch, register_core_models_schema): account_schema = registry.schema.get_node_schema(name=InfrahubKind.ACCOUNT, branch=default_branch)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-v2p7-4pv4-3wwhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-59036ghsaADVISORY
- github.com/opsmill/infrahub/commit/215185f217e2f754f7c0a0aa4b77e11079a063a1ghsaWEB
- github.com/opsmill/infrahub/commit/61b49a4a9e988f10c3a44f0e86ef97f344a1e228ghsaWEB
- github.com/opsmill/infrahub/releases/tag/infrahub-v1.3.9ghsaWEB
- github.com/opsmill/infrahub/releases/tag/infrahub-v1.4.5ghsaWEB
- github.com/opsmill/infrahub/security/advisories/GHSA-v2p7-4pv4-3wwhnvdWEB
News mentions
0No linked articles in our index yet.