medium

CVE-2026-53769

RubyGems · avo

Summary

Avo: Direct attachment upload endpoint lacks upload authorization and bypasses field-level upload policy

Severity
medium
CVSS
6.5
EPSS
0.3% (p17)
CWE
CWE-862, CWE-863
Also known as
GHSA-pqpw-cvm4-8mv9
Published
2026-07-09
Updated
2026-07-09

Advisory details

Summary

Avo's direct attachment upload endpoint lacks server-side upload authorization and bypasses the documented field-level upload policy methods such as upload_{FIELD_ID}?.

An authenticated Avo user who can reach the Avo attachment upload endpoint can replace or add attachment content, including binary content, filename, and content-type metadata, on a resolved record even when both update? and upload_<field>? policies deny the operation.

This primarily affects multi-role Avo Pro/Advanced-style deployments where non-administrator or restricted operator users can reach Avo and per-record or per-field operations are expected to be enforced by policies.

Details

Avo exposes a direct attachment upload endpoint:

POST /<avo-root>/avo_api/resources/:resource_name/:id/attachments/

The vulnerable code path is Avo::AttachmentsController#create:

Current behavior:

def create
  blob = ActiveStorage::Blob.create_and_upload! io: params[:file].to_io, filename: params[:filename]
  association_name = BaseResource.valid_attachment_name(@record, params[:attachment_key])

  if association_name
    @record.send(association_name).attach blob
  elsif params[:key].blank?
    raise ActionController::BadRequest.new(...)
  end

  render json: {
    url: main_app.url_for(blob),
    href: main_app.url_for(blob)
  }
end

This endpoint creates an ActiveStorage::Blob before validating the requested attachment association and before any upload authorization could be enforced. If attachment_key resolves to an association, the blob is attached to the target record. If the request uses the key-based/Trix path, the endpoint can still persist the blob and return url/href even when no attachment association is resolved on the target record.

The controller never calls:

This is inconsistent with the rest of Avo's file authorization implementation. The field-level file authorization concern defines upload authorization as:

def can_upload_file?
  authorize_file_action(:upload)
end

def authorize_file_action(action)
  authorize_action("#{action}_#{id}?", record: record, raise_exception: false)
end

That upload policy is used by UI components to decide whether to render upload controls, but the server-side upload endpoint does not enforce the same policy. A user can therefore bypass the policy by directly POSTing to the endpoint.

By contrast, attachment deletion does call attachment-specific authorization:

def destroy
  if authorized_to :delete
    ...
  end
end

def authorized_to(action)
  @resource.authorization.authorize_action("#{action}_#{params[:attachment_name]}?", record: @record, raise_exception: false)
end

The asymmetry is:

The field-level upload authorization intent is also documented by Avo:

Affected versions observed:

Suggested affected range for the GitHub Security Advisory form:

>= 2.28.0

Rationale:

From a narrow missing-authorization perspective, the issue exists from v1.4.0; the >= 2.28.0 range is a conservative submission range anchored to the documented field-level upload policy boundary.

Fixed version: to be determined by maintainers.

PoC

A local request spec was used to reproduce the issue. The PoC adds pundit to the test group and replaces Avo::Services::AuthorizationService with a test double. The test double records every authorize_action invocation and, when invoked, delegates the decision to a PostPolicy resolved via Pundit.policy!.

The policy setup is:

The open-source repository does not include Avo Pro's authorization client. The critical observation is independent of which client is plugged in: the vulnerable endpoint never invokes authorize_action at all, so the call list remains empty.

The PoC user has roles: {admin: true}, which is required only to pass the dummy app's coarse route-level guard:

authenticate :user, ->(user) { user.is_admin? }

The vulnerability under test is one layer below that guard: the fine-grained record/field authorization that Avo Pro/Pundit deployments would normally enforce. The dummy route gate is not part of Avo's upload policy decision. In a deployment where non-admin operators reach Avo through a different authentication rule, AttachmentsController#create would still skip authorize_action for the upload.

Policy scoping is orthogonal to this finding. Even if apply_policy filtered the record set during lookup, AttachmentsController#create would still not authorize the upload action itself for records that survive the scope.

The spec demonstrates:

  1. Normal record update is denied by policy and does not persist changes.
  2. Direct upload with attachment_key=cover_photo succeeds even though PostPolicy#upload_cover_photo? returns false, replacing the existing has_one_attached cover_photo blob.
  3. Direct upload with attachment_key=attachments succeeds even though PostPolicy#upload_attachments? returns false, adding to a has_many_attached association.
  4. Direct upload with params[:key] and no attachment association succe

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.