CVE-2026-28681
Description
Internet Routing Registry daemon version 4 is an IRR database server, processing IRR objects in the RPSL format. From version 4.4.0 to before version 4.4.5 and from version 4.5.0 to before version 4.5.1, an attacker can manipulate the HTTP Host header on a password reset or account creation request. The confirmation link in the resulting email can then point to an attacker-controlled domain. Opening the link in the email is sufficient to pass the token to the attacker, who can then use it on the real IRRD instance to take over the account. A compromised account can then be used to modify RPSL objects maintained by the account's mntners and perform other account actions. If the user had two-factor authentication configured, which is required for users with override access, an attacker is not able to log in, even after successfully resetting the password. This issue has been patched in versions 4.4.5 and 4.5.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
irrdPyPI | >= 4.4.0, < 4.4.5 | 4.4.5 |
irrdPyPI | >= 4.5.0, < 4.5.1 | 4.5.1 |
Affected products
1- cpe:2.3:a:internet_routing_registry_daemon_project:internet_routing_registry_daemon:*:*:*:*:*:*:*:*Range: >=4.4.0,<4.4.5
Patches
28408e0f1b9f4[4.4.x] Add Host header invalidation and invalidate all pw reset tokens
5 files changed · +127 −5
irrd/integration_tests/run.py+32 −5 modified@@ -579,8 +579,8 @@ def test_irrd_integration(self, tmpdir): self.check_graphql() def check_http(self): - status1 = requests.get(f"http://127.0.0.1:{self.port_http1}/v1/status/") - status2 = requests.get(f"http://127.0.0.1:{self.port_http2}/v1/status/") + status1 = requests.get(f"http://localhost:{self.port_http1}/v1/status/") + status2 = requests.get(f"http://localhost:{self.port_http2}/v1/status/") assert status1.status_code == 200 assert status2.status_code == 200 assert "IRRD version" in status1.text @@ -592,8 +592,14 @@ def check_http(self): assert "Authoritative: Yes" in status1.text assert "Authoritative: Yes" not in status2.text + # GHSA-22m3-c7vp-49fj CVE-2026-28681 + wrong_host = requests.get( + f"http://localhost:{self.port_http1}/v1/status/", headers={"Host": "attacker.com"} + ) + assert wrong_host.status_code == 400 + def check_graphql(self): - client = GraphqlClient(endpoint=f"http://127.0.0.1:{self.port_http1}/graphql/") + client = GraphqlClient(endpoint=f"http://localhost:{self.port_http1}/graphql/") # Regular rpslObjects query including journal and several references query = """query { rpslObjects(rpslPk: "PERSON-TEST") { @@ -838,8 +844,6 @@ def _start_irrds(self): "http": { "status_access_list": "localhost", "interface": "::1", - "port": 8080, - "url": "https://localhost:8080/", }, "whois": {"interface": "::1", "max_connections": 10, "port": 8043}, }, @@ -887,6 +891,7 @@ def _start_irrds(self): config1["irrd"]["redis_url"] = self.redis_url1 config1["irrd"]["server"]["http"]["interface"] = "127.0.0.1" # #306 config1["irrd"]["server"]["http"]["port"] = self.port_http1 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http1}/" config1["irrd"]["server"]["whois"]["interface"] = "127.0.0.1" config1["irrd"]["server"]["whois"]["port"] = self.port_whois1 config1["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg1" @@ -907,6 +912,7 @@ def _start_irrds(self): config2["irrd"]["database_url"] = self.database_url2 config2["irrd"]["redis_url"] = self.redis_url2 config2["irrd"]["server"]["http"]["port"] = self.port_http2 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http2}/" config2["irrd"]["server"]["whois"]["port"] = self.port_whois2 config2["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg2" config2["irrd"]["log"]["logfile_path"] = self.logfile2 @@ -925,6 +931,27 @@ def _start_irrds(self): with open(self.config_path2, "w") as yaml_file: yaml.safe_dump(config2, yaml_file) +<<<<<<< HEAD +======= + config3 = base_config.copy() + config3["irrd"]["piddir"] = self.piddir3 + config3["irrd"]["database_url"] = self.database_url3 + config3["irrd"]["redis_url"] = self.redis_url3 + config3["irrd"]["server"]["http"]["port"] = self.port_http3 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http3}/" + config3["irrd"]["server"]["whois"]["port"] = self.port_whois3 + config3["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg3" + config3["irrd"]["log"]["logfile_path"] = self.logfile3 + config3["irrd"]["rpki"]["roa_source"] = None + config3["irrd"]["sources"]["TEST"] = { + "keep_journal": True, + "nrtm4_client_notification_file_url": f"file://{self.nrtm4_dir2}{UPDATE_NOTIFICATION_FILENAME}", + "nrtm4_client_initial_public_key": eckey_public_key_as_str(self.nrtm4_private_key), + } + with open(self.config_path3, "w") as yaml_file: + yaml.safe_dump(config3, yaml_file) + +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens) self._prepare_database() assert not subprocess.call(["irrd/daemon/main.py", f"--config={self.config_path1}"])
irrd/server/http/app.py+4 −0 modified@@ -2,6 +2,7 @@ import os import signal from pathlib import Path +from urllib.parse import urlparse import limits from ariadne.asgi import GraphQL @@ -11,6 +12,7 @@ from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import RedirectResponse from starlette.routing import Mount, Route, WebSocketRoute from starlette.staticfiles import StaticFiles @@ -144,13 +146,15 @@ def set_middleware(app): testing = os.environ.get("TESTING", False) if testing: logger.info("Running in testing mode, disabling CSRF.") + allowed_host = urlparse(get_setting("server.http.url")).hostname app.user_middleware = [ # Use asgi-log to work around https://github.com/encode/uvicorn/issues/1384 Middleware( AccessLoggerMiddleware, logger=logger, format='%(client_addr)s - "%(request_line)s" %(status_code)s - %(L)ss', ), + Middleware(TrustedHostMiddleware, allowed_hosts=[allowed_host]), Middleware(MemoryTrimMiddleware), Middleware(SessionMiddleware, secret_key=secret_key_derive("web.session_middleware")), Middleware(
irrd/webui/auth/users.py+6 −0 modified@@ -161,6 +161,12 @@ def validate_token(self, token: str) -> bool: except ValueError: return False +<<<<<<< HEAD def _hash(self, expiry_days: Union[int, str]) -> bytes: hash_data = secret_key_derive("web.password_reset_token") + self.user_key + str(expiry_days) +======= + def _hash(self, expiry_days: int | str) -> bytes: + # https://github.com/irrdnet/irrd/security/advisories/GHSA-22m3-c7vp-49fj + hash_data = secret_key_derive("web.password_reset_token2") + self.user_key + str(expiry_days) +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens) return hashlib.sha224(hash_data.encode("utf-8")).digest()
poetry.lock+79 −0 modified@@ -1,4 +1,20 @@ +<<<<<<< HEAD # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +======= +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens) [[package]] name = "aiohttp" @@ -292,7 +308,12 @@ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" optional = false +<<<<<<< HEAD python-versions = ">=3.7" +======= +python-versions = ">=3.9" +groups = ["main", "dev"] +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens) files = [ {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, @@ -1971,6 +1992,33 @@ files = [ {file = "py-radix-sr-1.0.0.post1.tar.gz", hash = "sha256:e0c0f922380856bbdf785c701f67661f1b5c5cb6779308532ce3c27a6204cd7d"}, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + [[package]] name = "pycparser" version = "2.21" @@ -2479,6 +2527,31 @@ files = [ {file = "ruff-0.0.252.tar.gz", hash = "sha256:6992611ab7bdbe7204e4831c95ddd3febfeece2e6f5e44bbed044454c7db0f63"}, ] +[[package]] +name = "service-identity" +version = "24.2.0" +description = "Service identity verification for pyOpenSSL & cryptography." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85"}, + {file = "service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09"}, +] + +[package.dependencies] +attrs = ">=19.1.0" +cryptography = "*" +pyasn1 = "*" +pyasn1-modules = "*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "idna", "mypy", "pyopenssl", "pytest", "types-pyopenssl"] +docs = ["furo", "myst-parser", "pyopenssl", "sphinx", "sphinx-notfound-page"] +idna = ["idna"] +mypy = ["idna", "mypy", "types-pyopenssl"] +tests = ["coverage[toml] (>=5.0.2)", "pytest"] + [[package]] name = "setproctitle" version = "1.3.2" @@ -3559,6 +3632,12 @@ files = [ ] [metadata] +<<<<<<< HEAD lock-version = "2.0" python-versions = "^3.8" content-hash = "8f62be1fc5e4d30f33c11b88106dc10a1d7afce8ef4b77cc990b933990eab825" +======= +lock-version = "2.1" +python-versions = ">3.10.0,<4.0" +content-hash = "f270f379f6f1d7f81bdc5302a35c99fc4325985b285d5c8799c7462e764d7c66" +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens)
pyproject.toml+6 −0 modified@@ -63,6 +63,12 @@ zxcvbn = "4.4.28" wtforms-bootstrap5 = "0.2.3" email-validator = "2.0.0post2" asgi-logger = "0.1.0" +<<<<<<< HEAD +======= +joserfc = "1.6.3" +time-machine = "3.2.0" +service-identity = "^24.2.0" +>>>>>>> 1045a2f (Add Host header invalidation and invalidate all pw reset tokens) [tool.poetry.group.dev.dependencies] pytest = "^7.2.1"
cf62df4a49d3[4.5.x] Add Host header invalidation and invalidate all pw reset tokens
5 files changed · +73 −8
irrd/integration_tests/run.py+12 −5 modified@@ -581,8 +581,8 @@ def test_irrd_integration(self, tmpdir): self.check_graphql() def check_http(self): - status1 = requests.get(f"http://127.0.0.1:{self.port_http1}/v1/status/") - status2 = requests.get(f"http://127.0.0.1:{self.port_http2}/v1/status/") + status1 = requests.get(f"http://localhost:{self.port_http1}/v1/status/") + status2 = requests.get(f"http://localhost:{self.port_http2}/v1/status/") assert status1.status_code == 200 assert status2.status_code == 200 assert "IRRD version" in status1.text @@ -594,8 +594,14 @@ def check_http(self): assert "Authoritative: Yes" in status1.text assert "Authoritative: Yes" not in status2.text + # GHSA-22m3-c7vp-49fj CVE-2026-28681 + wrong_host = requests.get( + f"http://localhost:{self.port_http1}/v1/status/", headers={"Host": "attacker.com"} + ) + assert wrong_host.status_code == 400 + def check_graphql(self): - client = GraphqlClient(endpoint=f"http://127.0.0.1:{self.port_http1}/graphql/") + client = GraphqlClient(endpoint=f"http://localhost:{self.port_http1}/graphql/") # Regular rpslObjects query including journal and several references query = """query { rpslObjects(rpslPk: "PERSON-TEST") { @@ -858,8 +864,6 @@ def _start_irrds(self): "http": { "status_access_list": "localhost", "interface": "::1", - "port": 8080, - "url": "https://localhost:8080/", }, "whois": {"interface": "::1", "max_connections": 10, "port": 8043}, }, @@ -907,6 +911,7 @@ def _start_irrds(self): config1["irrd"]["redis_url"] = self.redis_url1 config1["irrd"]["server"]["http"]["interface"] = "127.0.0.1" # #306 config1["irrd"]["server"]["http"]["port"] = self.port_http1 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http1}/" config1["irrd"]["server"]["whois"]["interface"] = "127.0.0.1" config1["irrd"]["server"]["whois"]["port"] = self.port_whois1 config1["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg1" @@ -928,6 +933,7 @@ def _start_irrds(self): config2["irrd"]["database_url"] = self.database_url2 config2["irrd"]["redis_url"] = self.redis_url2 config2["irrd"]["server"]["http"]["port"] = self.port_http2 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http2}/" config2["irrd"]["server"]["whois"]["port"] = self.port_whois2 config2["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg2" config2["irrd"]["log"]["logfile_path"] = self.logfile2 @@ -954,6 +960,7 @@ def _start_irrds(self): config3["irrd"]["database_url"] = self.database_url3 config3["irrd"]["redis_url"] = self.redis_url3 config3["irrd"]["server"]["http"]["port"] = self.port_http3 + config1["irrd"]["server"]["http"]["url"] = f"https://localhost:{self.port_http3}/" config3["irrd"]["server"]["whois"]["port"] = self.port_whois3 config3["irrd"]["auth"]["gnupg_keyring"] = str(self.tmpdir) + "/gnupg3" config3["irrd"]["log"]["logfile_path"] = self.logfile3
irrd/server/http/app.py+4 −0 modified@@ -3,6 +3,7 @@ import os import signal from pathlib import Path +from urllib.parse import urlparse import limits from ariadne.asgi import GraphQL @@ -12,6 +13,7 @@ from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import RedirectResponse from starlette.routing import Mount, Route, WebSocketRoute from starlette.staticfiles import StaticFiles @@ -148,13 +150,15 @@ def set_middleware(app): csrf_disabled = testing and not getattr(app, "force_csrf_in_testing", False) if csrf_disabled: logger.info("Running in testing mode, disabling CSRF.") + allowed_host = urlparse(get_setting("server.http.url")).hostname app.user_middleware = [ # Use asgi-log to work around https://github.com/encode/uvicorn/issues/1384 Middleware( AccessLoggerMiddleware, logger=logger, format='%(client_addr)s - "%(request_line)s" %(status_code)s - %(L)ss', ), + Middleware(TrustedHostMiddleware, allowed_hosts=[allowed_host]), Middleware(MemoryTrimMiddleware), Middleware(SessionMiddleware, secret_key=secret_key_derive("web.session_middleware")), Middleware(
irrd/webui/auth/users.py+2 −1 modified@@ -159,5 +159,6 @@ def validate_token(self, token: str) -> bool: return False def _hash(self, expiry_days: int | str) -> bytes: - hash_data = secret_key_derive("web.password_reset_token") + self.user_key + str(expiry_days) + # https://github.com/irrdnet/irrd/security/advisories/GHSA-22m3-c7vp-49fj + hash_data = secret_key_derive("web.password_reset_token2") + self.user_key + str(expiry_days) return hashlib.sha224(hash_data.encode("utf-8")).digest()
poetry.lock+54 −2 modified@@ -365,7 +365,7 @@ version = "25.4.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, @@ -2740,6 +2740,33 @@ files = [ {file = "py_radix_sr-1.0.2.tar.gz", hash = "sha256:f3727d46a5dda4b5d159d8da0f12a2b23b21120e92d8ed30c192727016d919fd"}, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + [[package]] name = "pycparser" version = "3.0" @@ -3408,6 +3435,31 @@ files = [ {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"}, ] +[[package]] +name = "service-identity" +version = "24.2.0" +description = "Service identity verification for pyOpenSSL & cryptography." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85"}, + {file = "service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09"}, +] + +[package.dependencies] +attrs = ">=19.1.0" +cryptography = "*" +pyasn1 = "*" +pyasn1-modules = "*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "idna", "mypy", "pyopenssl", "pytest", "types-pyopenssl"] +docs = ["furo", "myst-parser", "pyopenssl", "sphinx", "sphinx-notfound-page"] +idna = ["idna"] +mypy = ["idna", "mypy", "types-pyopenssl"] +tests = ["coverage[toml] (>=5.0.2)", "pytest"] + [[package]] name = "setproctitle" version = "1.3.7" @@ -4904,4 +4956,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.10.0,<4.0" -content-hash = "0a4f6b5c571ab1345e6b2dd5e346823db1fb1b20320903ddda660d002c79d865" +content-hash = "f270f379f6f1d7f81bdc5302a35c99fc4325985b285d5c8799c7462e764d7c66"
pyproject.toml+1 −0 modified@@ -65,6 +65,7 @@ email-validator = "2.3.0" asgi-logger = "0.1.0" joserfc = "1.6.3" time-machine = "3.2.0" +service-identity = "^24.2.0" [tool.poetry.group.dev.dependencies] pytest = "^7.2"
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/irrdnet/irrd/commit/8408e0f1b9f47eb2f2e712d6153e32194df05fbbnvdPatchWEB
- github.com/irrdnet/irrd/commit/cf62df4a49d3891e80b2879d9b324d1af050000cnvdPatchWEB
- github.com/advisories/GHSA-22m3-c7vp-49fjghsaADVISORY
- github.com/irrdnet/irrd/security/advisories/GHSA-22m3-c7vp-49fjnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28681ghsaADVISORY
- irrd.readthedocs.io/en/stable/releases/4.4.5nvdRelease NotesWEB
- irrd.readthedocs.io/en/stable/releases/4.5.1nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.