← Zpět na zprávu

Folio CMS — security and maintainability review

Scope: inputs/repos/folio, HEAD 71084c978cbe9c65e6ce36a9ae4dcf841bc7693e (2026-09-14, Petr Marek), Folio VERSION = 7.7.2. Audience: Auction house running Auctea (Folio + Auctify) on www.acb.cz, deciding whether to keep Folio. Reviewer position: adopter-side, defensive. Folio is a Rails engine. The closed Auctea app can override any of it. Every finding below states what I proved by reading Folio's code, and what must be checked in Auctea before the finding is final.

Score
Security4 / 10
Maintainability6 / 10

0. Method and limits


1. File upload and media

1.1 Content type is read from the bytes, not from the client — this part is correct

config/initializers/dragonfly.rb:67-71 registers a Dragonfly analyser that shells out to file --brief --mime-type on the stored temp file. Dragonfly's magic attribute file_mime_type is populated from that analyser, not from the browser's Content-Type header. Every format validation in app/models/concerns/folio/dragonfly_format_validation.rb:47-55 then compares against that byte-derived value.

This is the right design and I want to say so plainly. Most Rails CMSes trust params[:file].content_type. Folio does not.

1.2 CRITICAL — Folio::File::Document accepts any file type at all

SeverityHIGH
StatusPROVED by reading

app/models/folio/file/image.rb:7, app/models/folio/file/video.rb:6 and app/models/folio/file/audio.rb:28 each call validate_file_format. app/models/folio/file/document.rb (7 lines, whole file) calls nothing. Folio::File itself only validates presence (app/models/folio/file.rb:91-92).

So Folio::File::Document accepts .html, .svg, .xhtml, .js, .exe, anything. Combined with §1.4 below (the file is then downloadable by an unauthenticated stranger over /download/:slug), an editor — or anyone who compromises one editor account — can host an arbitrary HTML page on auction-house infrastructure.

Consequence for the auction house: a phishing page that looks like the acb.cz login, served from a URL the house's own staff will vouch for. Czech bidders enter credentials; the attacker bids or withdraws on their behalf. Money loss is bounded only by the hammer prices in play.

Confirm in Auctea: grep -rn "validate_file_format" app/models/ in the Auctea repo. If no Folio::File::Document subclass adds one, the finding stands as written.

1.3 HIGH — SVG is an accepted image format

SeverityMEDIUM, rising to HIGH depending on CDN host
StatusPROVED that SVG is accepted; impact SUSPECTED

app/models/folio/file/image.rb:7 allows svg, which app/models/concerns/folio/dragonfly_format_validation.rb:14 maps to image/svg+xml. SVG is a script-bearing document format. Folio is aware of it — app/cells/folio/image_cell.rb:67 and app/components/folio/console/ui/image_component.rb:119 both special-case SVG to skip retina/WebP derivative generation — but nothing sanitises the SVG body and nothing forces a Content-Disposition: attachment.

Files are served from S3, rewritten to ENV["S3_CDN_HOST"] by app/lib/folio/s3.rb:13-21. Whether a stored <svg onload=…> is same-origin XSS therefore depends on one variable:

Confirm: curl -sI https://www.acb.cz/<any page> | grep -i set-cookie for the cookie Domain, and read the src host of any <img> on the site. Two commands, five minutes.

1.4 CRITICAL — /download/:slug is unauthenticated, unscoped, and enumerable by integer

SeverityCRITICAL
StatusPROVED by reading; confirmed by Folio's own test

app/controllers/folio/downloads_controller.rb in full:

class Folio::DownloadsController < ActionController::Base   # :8
  layout false
  before_action :find_file
  def show
    redirect_to Folio::S3.cdn_url_rewrite(@file.file.remote_url), allow_other_host: true   # :13
  end
  private
    def find_file
      @file = Folio::File.friendly.find(params[:slug])                # :18
    rescue ActiveRecord::RecordNotFound
      @file = Folio::PrivateAttachment.friendly.find(params[:slug])   # :20
    end
end

Route: config/routes.rb:343 — get "/download/:slug(/*name)".

Four separate defects in thirteen lines:

  1. It inherits ActionController::Base, not Folio::ApplicationController. No authenticate_user!, no authorize!, no Folio::Current setup, so no site context exists at all.
  2. Folio::File.friendly.find is unscoped by site. There is no by_site, no default scope (§6.1).
  3. Numeric slugs resolve to the primary key. This is deliberate — the comment at app/models/folio/file.rb:448-456 explains that normalize_friendly_id forces a non-numeric slug precisely so that friendly.find("349444") reaches id 349444. FriendlyId's potential_primary_key? fallback does the rest. So GET /download/1, /download/2, /download/3 … walks the entire folio_files table, across every tenant on the deployment.
  4. The fallback at line 20 reaches Folio::PrivateAttachment — the model an auction house uses for consignment agreements, bidder ID documents, valuations. Folio::PrivateAttachment (app/models/folio/private_attachment.rb, 47 lines) has no format validation, no site column, and no private storage headers — contrast app/models/folio/session_attachment/base.rb:18, which does set "x-amz-acl" => "private". Its slug is a hash_id of only SecureRandom.urlsafe_base64(4) (app/models/concerns/folio/has_hash_id.rb:27 with hash_id_length defaulting to 4 at line 40) — about 32 bits, and there is no rate limiting anywhere in the engine (grep -rn "rate_limit\|Rack::Attack\|throttle" over app lib config returns nothing).

Folio's own test confirms no authentication is involved — test/controllers/folio/downloads_controller_test.rb:8-13 calls get download_path(doc, …) with no sign_in and asserts a successful redirect.

Consequence for the auction house: a stranger with curl and a for-loop retrieves every document in the media library of every auction house sharing the deployment — unpublished catalogues, condition reports, provenance files, reserve-price notes, and any consignor paperwork stored as a Folio::File::Document or Folio::PrivateAttachment. Under GDPR this is an Article 33 reportable breach if any of those files contain bidder identity data, and the ÚOOÚ fine ceiling is 4 % of global turnover. Commercially, leaking reserve prices before a sale destroys the sale.

Confirm: curl -sI https://www.acb.cz/download/1 from outside. A 302 to an S3/CDN URL confirms it live.

1.5 HIGH — unauthenticated upload endpoint with no type check, no size cap, no rate limit

SeverityHIGH
StatusPROVED for the engine; per-subclass validation SUSPECTED

app/controllers/folio/session_attachments_controller.rb inherits Folio::ApplicationController and adds only before_action :init_session_if_needed (line 6). There is no authenticate_user!. Route: config/routes.rb:311.

app/models/folio/session_attachment/base.rb validates presence of web_session_id and file (lines 29-30) and rejects types starting with "Folio::" (line 104). It does not call validate_file_format, and no size limit exists anywhere — grep -rn "file_size\|max_size\|content_length" over app/models app/controllers config lib finds only display helpers, no validation.

CSRF protection does apply (protect_from_forgery with: :exception, app/controllers/concerns/folio/application_controller_base.rb:15), so this is not a drive-by. It is a scripted attack: fetch a CSRF token from /csrf (config/routes.rb, resource :csrf), then POST files in a loop.

Consequence: S3 storage and egress billed to the auction house, without limit. Also attachment.save! at line 12 raises on validation failure, so a malformed upload returns HTTP 500 and fills Sentry rather than returning 422.

Confirm in Auctea: whether the concrete SessionAttachment subclasses call validate_file_format, and whether a WAF or client_max_body_size in front of Rails caps upload size.

1.6 HIGH — Dragonfly's URL-signing secret has a hardcoded fallback committed to the repo

SeverityHIGH if the env var is unset, otherwise none
StatusPROVED that the fallback exists; production use SUSPECTED

config/initializers/dragonfly.rb:162:

secret ENV.fetch("DRAGONFLY_SECRET", "4ergbu7gheriufgle78osferyufler78f8oelgfyegfia34g7fsey,fghr.fguiye4.gfu.dgfja,sgfuielgfiuagwekf,guyrawilfguireal")

Dragonfly::Middleware is mounted on every request (config/initializers/dragonfly.rb:4) and serves url_format "/media/:job/:sha/:name" (line 164). The :sha is an HMAC over the serialised job using that secret. It is the only thing stopping a stranger from crafting arbitrary Dragonfly jobs. With a publicly known secret, an attacker signs their own jobs and can reach Dragonfly's fetch_url step (server-side request forgery from inside the auction house's network — AWS instance metadata, internal admin services) and fetch_file (arbitrary local file read: config/master.key, .env, /proc/self/environ).

It also puts an attacker in reach of the two shell processors that disable escaping or interpolate paths directly:

Consequence: if DRAGONFLY_SECRET is unset in production this is full read access to the application server's filesystem, which means the Rails secret_key_base and therefore forged sessions for any bidder or administrator.

Confirm — this is the single highest-value five-minute check in this report. Take any /media/:job/:sha/:name URL off www.acb.cz, recompute the signature locally with the committed secret above, and compare to the :sha in the URL. If they match, DRAGONFLY_SECRET is unset and this is CRITICAL. Also simply check the Auctea deploy environment for the variable.

1.7 Image processing runs on untrusted bytes — five separate binaries

SeverityMEDIUM
StatusPROVED

Uploaded bytes are handed to, in order: libvips (config/initializers/dragonfly.rb:26,38,45,75), file(1) (line 68), ExifTool (line 142, Open3.capture3), jpgicc/liblcms2 (line 92), jpegtran (line 111), ffmpeg (line 118, plus app/models/folio/file.rb:504 GetVideoMetadataJob) and cwebp (line 125).

That is seven native parsers reachable from a file upload. libvips, ExifTool and ffmpeg all have a steady CVE history for memory-safety bugs on malformed input. The ExifTool call is correctly built as an argv array with Open3.capture3 (line 143) — no shell — which is good. But app/models/folio/file/image.rb:14 calls file.metadata in an after_assign hook, i.e. ExifTool runs synchronously inside the request, before validation, on bytes that have not yet been format-checked.

Consequence: a malicious image crashes or hangs a Puma worker at minimum. Historically (ExifTool CVE-2021-22204, the "Wizard Exploit") it has meant remote code execution. There is no sandbox, no seccomp, no separate processing host in Folio's configuration.

Mitigation the house can apply without touching Folio: move metadata extraction off the request path and run the processors in a container with no network and a read-only root.


2. The admin console

2.1 The main console path is well built — say so

app/controllers/folio/console/base_controller.rb:11-12 chains authenticate_user! then custom_authorize_user!, which (lines 439-453) calls authorize!(:access_console, Folio::Current.site). The class macro folio_console_controller_for (lines 36-114) then wires CanCanCan's load_and_authorize_resource and a site filter (filter_records_by_belongs_to_site, lines 506-515) that raises ActiveRecord::RecordNotFound when a loaded record's site is not in allowed_record_sites. Record lookup goes through find_console_resource (lines 537-545), which carefully prefers the primary key for numeric params to avoid a slug/id collision — the comment at 531-536 shows someone thought this through.

Folio::Ability (app/models/folio/ability.rb) is small, readable, and fails closed: initialize returns immediately for a nil user (line 8), and can :access_console is granted only inside console_common_admin_rules (line 86), reached only by a superadmin or a holder of :administrator/:manager on the current site (lines 37, 55).

2.2 CRITICAL — a site administrator can grant themselves administrator on another tenant's site

SeverityCRITICAL
StatusPROVED by reading the full chain

Four pieces, each defensible alone:

(a) app/models/folio/ability.rb:63 and :74:

can :read_administrators, Folio::Site
can :set_administrator, Folio::Site      # ← no condition hash
can :set_manager, Folio::Site            # ← no condition hash

Compare line 110 in the same file, where the authors did scope a Site rule: can %i[read update publish_header_message], Folio::Site, { id: site.id }. The set_* rules are missing that { id: site.id }.

(b) app/controllers/folio/console/users_controller.rb:174 permits site_id freely:

ary << { site_user_links_attributes: [:site_id, :locked, roles: []] }

(c) app/models/concerns/folio/has_site_roles.rb:47-59 is a hand-written override of the nested-attributes writer. It does Folio::Site.find(link_attributes["site_id"].to_i.abs) — any site id, no scoping, no ability check on the site itself — and passes it to set_roles_for.

(d) The one guard that exists, app/models/folio/site_user_link.rb:46-56, calls Folio::Current.user.can_manage_role?(role, self.site) → can_now?(:"set_#{role}", site) (app/models/concerns/folio/has_site_roles.rb:43-45). And app/models/folio/user.rb:310-315:

def can_now?(action, subject = nil, site: nil)
  site ||= (subject&.try(:site) || ::Folio::Current.main_site)
  subject = site if subject.blank?
  ability = ::Folio::Current.ability || Folio::Ability.new(self, site)   # ← :313
  can_now_by_ability?(ability, action, subject)
end

Line 313 uses the request's already-built ability — built for acb.cz — and never rebuilds it for the target site. The subject is the other Folio::Site. The rule from (a) has no condition. ability.can?(:set_administrator, other_site) returns true.

Exploit, as an ordinary acb.cz console administrator:

PATCH /console/users/<my own id>
  user[site_user_links_attributes][1][site_id]=<other auction house's site id>
  user[site_user_links_attributes][1][roles][]=administrator

authorize! :update, @user passes because I already have a link to acb.cz (can :do_anything, Folio::User, { site_user_links: { site: }, superadmin: false }, ability.rb:60). Site ids are small integers; Folio::Site.pluck(:title, :id) is even exposed to superadmins at users_controller.rb:159. Then log in at the other auction house's domain with full console rights.

The manager role gets the same via can :set_manager, Folio::Site (line 74).

Consequence for the auction house: this runs in both directions. Any administrator at any other auction house sharing the Auctea deployment can promote themselves to administrator of acb.cz, then read consignor lists, reserve prices and bidder identities, alter lot descriptions, or place and cancel bids on lots worth millions of CZK. This is a competitor-grade breach, not a theoretical one, and it is a single HTTP request.

Confirm in Auctea: whether Folio::Ability#folio_rules is overridden and re-scopes :set_administrator / :set_manager. Grep the Auctea repo for set_administrator. If it is not overridden, treat this as a live incident.

2.3 HIGH — CanCanCan's check_authorization is never enabled, and fourteen console controllers rely on that

SeverityHIGH (structural)
StatusPROVED

grep -rn "check_authorization\|skip_authorization_check" app lib test returns nothing. CanCanCan ships check_authorization precisely to raise when a controller action completes without calling authorize!. Folio does not use it, so a new console action that forgets authorisation fails silently open — it is protected only by the coarse authorize!(:access_console, site) on the base class, which every editor at every tenant passes.

Fourteen console controllers contain no folio_console_controller_for, no load_and_authorize_resource and no authorize!:

app/controllers/folio/console/api/aasm_controller.rb
app/controllers/folio/console/api/autocompletes_controller.rb
app/controllers/folio/console/api/base_controller.rb
app/controllers/folio/console/api/current_users_controller.rb
app/controllers/folio/console/api/jw_player_controller.rb
app/controllers/folio/console/api/links_controller.rb
app/controllers/folio/console/api/tags_controller.rb
app/controllers/folio/console/api/tiptap_controller.rb
app/controllers/folio/console/atoms_controller.rb
app/controllers/folio/console/current_users_controller.rb
app/controllers/folio/console/dashboard_controller.rb
app/controllers/folio/console/merges_controller.rb
app/controllers/folio/console/transports_controller.rb
app/controllers/folio/console/ui_controller.rb

Several of these do compensate correctly — jw_player_controller.rb:7 re-checks can_now?(:show, file), api/current_users_controller.rb:68 re-checks can_now?(:update, record), ui_controller.rb:4 restricts to superadmins, api/aasm_controller.rb:11 delegates to allowed_events_for. Three do not, and are written up below (§2.4, §2.5, §4.2).

The skip_before_action audit is short and, in itself, clean:

File:lineWhat it skipsVerdict
app/controllers/folio/console/api/base_controller.rb:6-7authenticate_user!, custom_authorize_user!Replaced by api_authorize_user! (line 19-21) which still requires :access_console. Acceptable — but it means every API controller is gated at console access only, never per-record.
app/controllers/folio/api/newsletter_subscriptions_controller.rb:7verify_authenticity_token on :createPublic subscribe form. invisible_captcha is a dependency. Acceptable.
app/controllers/folio/users/passwords_controller.rb:6require_no_authentication on :editStandard Devise pattern. Fine.
app/controllers/concerns/folio/api_controller_base.rb:11handle_crossdomain_deviseFine.

2.4 CRITICAL — TransportsController exports and imports any record of any tenant

SeverityCRITICAL
StatusPROVED by reading

app/controllers/folio/console/transports_controller.rb:36-52:

def get_record
  if params[:class_name].present? && params[:id].present?
    @klass = @class_name.safe_constantize
    @record = @klass.find(@id)                      # :42 — no by_site, no authorize!
    unless @klass && @klass < ActiveRecord::Base && @klass.try(:transportable?)   # :44

Routes: config/routes.rb:151-156 (get :out, get :download, post :transport).

``ruby @record.assign_attributes(@hash[:attributes].without(:id)) ``

Raw attribute assignment with no strong parameters. Any column of the target record, set from a user-supplied YAML blob. Combined with the unscoped find at line 42, a console user at acb.cz can overwrite another tenant's record — and importer.rb:34 then sets @record.site = find_site, moving the record into the attacker's own site.

Line 22 also does @hash[:attributes][:type].constantize.new on an unchecked class name — arbitrary class instantiation.

On the YAML itself: importer.rb:7 uses YAML.load, not YAML.safe_load. On Ruby ≤ 3.0 (Psych 3) that is object-deserialisation RCE. .ruby-version pins 3.3.3, so Psych 4 applies and YAML.load is safe-by-default. This is not RCE today, but it is one Ruby downgrade away from being RCE, and YAML.safe_load costs one word. Confirm the production Ruby is ≥ 3.1.

The test file test/controllers/folio/console/transports_controller_test.rb only exercises the superadmin path. The cross-tenant case is untested.

Consequence: unauthorised read of another auction house's unpublished content, plus a mass-assignment write primitive into arbitrary columns.

2.5 HIGH — AtomsController#placement_preview reads any record's content, any tenant

SeverityHIGH
StatusPROVED

app/controllers/folio/console/atoms_controller.rb:75-86:

def placement_preview
  klass = params.require(:klass).safe_constantize
  if klass < ActiveRecord::Base && klass.new.respond_to?(:atoms)
    @atoms = { I18n.locale => klass.find(params[:id]).atoms_in_molecules || [] }   # :80
    render :preview

Route config/routes.rb:292: get :placement_preview, path: "placement_preview/:klass/:id".

No authorize!. No by_site. GET /console/atoms/placement_preview/Folio::Page/<any id> renders the full atom content — including unpublished drafts — of any page belonging to any tenant, to any user who holds console access anywhere.

Minor, same file: klass < ActiveRecord::Base at line 77 runs before a nil check, so a bad klass param raises NoMethodError and returns 500 instead of 400.

Also in this controller, #preview (lines 29, 32) and #validate (line 92) call .constantize.new on user-supplied type and placement_type strings. Keys are constrained by Folio::Atom.strong_params, so this is not mass assignment, but it is arbitrary class instantiation — a denial-of-service and error-disclosure surface. MEDIUM.

2.6 MEDIUM — MergesController merges records with no authorisation check

app/controllers/folio/console/merges_controller.rb:4-41. It does scope by site when possible (lines 22-26 — @klass.by_site(Folio::Current.site) if the class responds to by_site), which is correct and deliberate. But there is no authorize! on the merge action itself, and create (line 47) calls @merger.merge(params.require(:merge).permit(@merger.permitted_params)) — so what is assignable depends entirely on each ::Merger class in Auctea. A console user with no rights over the model can still merge two of its records, which is destructive and irreversible.

Confirm in Auctea: enumerate *::Merger classes and read their permitted_params.


3. Public content rendering (editor HTML)

3.1 The sanitisation layer is genuinely good

app/models/concerns/folio/html_sanitization/model.rb is included in Folio::ApplicationRecord itself (app/models/folio/application_record.rb:9), so it applies to every Folio model by default, via before_validation :folio_html_sanitize (line 14).

app/lib/folio/html_sanitization/sanitizer.rb:66-77 is default-deny:

ConfigBehaviourWhere
:string (the default, line 6)Loofah.fragment(value).text — strips all markup:124-126
:rich_textRails::HTML5::SafeListSanitizer allow-list:114-118
:tiptap_contenthandled in the tiptap setter:73-74
:unsafe_htmlno sanitisation — explicit opt-out:67-68
anything elseraise ArgumentError:75-76

It recurses into Hash values (lines 85-102), so JSONB columns are covered. self.sanitize_href (lines 28-49) round-trips a candidate href through Rails' sanitiser and rejects it if the attribute was stripped — a neat, correct way to block javascript:.

Atoms opt in per field from their declared STRUCTURE (app/models/folio/atom/base.rb:346-361): :richtext → :rich_text, :code → :unsafe_html.

Two small notes, neither a defect worth acting on alone:

3.2 CRITICAL — the embed system executes editor-supplied <script> on the site's own origin, by design

SeverityCRITICAL for a site where bidders log in
StatusPROVED end to end

The chain:

  1. Storage is deliberately unsanitised. app/lib/folio/embed.rb:99-101, inside self.sanitize_value:

``ruby "html" => if normalized_value["html"].is_a?(String) normalized_value["html"] # ← returned verbatim end, ``

Note the contrast on the very next lines (103-105): the url field is put through Loofah. The html field is not. app/models/folio/file_placement/base.rb:60 marks folio_embed_data: :unsafe_html so the model-level sanitiser skips it too.

  1. folio_embed_data is a permitted console parameter. app/controllers/folio/console/base_controller.rb:228-233 adds it to image_or_embed_placements_attributes. It is also reachable through tiptap node attributes (app/lib/folio/tiptap/content.rb:76 routes :embed-typed values to Folio::Embed.sanitize_value).
  1. The renderer executes it. The value is handed to a Stimulus controller (app/components/folio/embed/box_component.rb:51) which inserts an iframe pointing at /folio/embed (app/components/folio/embed/box_component.js:97-99). That path is served by a Rack middleware from a static file (app/lib/rack/folio/embed_middleware.rb:13, :57). Inside that file, data/embed/dist/folio-embed-dist.html:281-297:

```js if (data.html) { container.innerHTML = data.html // :282 makeEmbeddedIframesResponsive(container, fullWidthIframes)

// Extract and execute scripts manually :285 const scripts = container.querySelectorAll('script') scripts.forEach(script => { const newScript = document.createElement('script') if (script.src) { newScript.src = script.src; newScript.async = script.async } else { newScript.textContent = script.textContent } document.head.appendChild(newScript) // :294 script.remove() }) ```

innerHTML alone would not run inline <script>. Lines 285-296 exist specifically to make it run. (Source: data/embed/source/embed.js:115.)

  1. The iframe has no sandbox attribute and is same-origin. box_component.js:99 writes <iframe class="f-embed-box__iframe" src="${src}"> where src is /folio/embed on the app's own host. There is no sandbox=, and there is no Content-Security-Policy anywhere in the engine — grep -rn "content_security_policy" app lib config returns nothing.

The postMessage handler does check the origin (folio-embed-dist.html:502, if (e.origin !== window.origin) return) — correct, and worth noting — but that defends the channel, not the payload.

Consequence for the auction house: any editor account, or any attacker who phishes one editor, gets arbitrary JavaScript running on www.acb.cz for every visitor, including logged-in bidders. Concretely: rewrite the displayed hammer price, silently change the bid amount a bidder is about to submit, harvest bidder session tokens, or exfiltrate the bidder list. With message_bus long-polling driving the live bidding UI (per the brief), a script on that origin sits in the same context as the bidding client. The realistic loss is the difference between a real and a manipulated bid on a lot worth millions of CZK, plus the legal exposure of a contested sale.

This is the finding I would act on first among the content ones. It is not a bug — it is a deliberate feature (third-party embeds such as Instagram and X require script execution). The fix is not to remove embeds; it is to serve /folio/embed from a separate origin (e.g. embeds-acb.example), which is a one-line hosting change plus a src change at box_component.js:97, and costs nothing functionally.

Confirm: the host serving /folio/embed on production, and whether any Content-Security-Policy header is set by the reverse proxy rather than by Rails.

3.3 LOW — Markdown is rendered without HTML filtering

app/helpers/folio/markdown_helper.rb:10-29 builds Redcarpet::Render::HTML.new(…) without filter_html: true or escape_html: true, then calls .html_safe (line 29). The only caller is app/views/folio/console/help_documents/show.slim:24, wrapped in raw(…).

The content comes from .md files on disk (app/models/folio/help_document.rb:60-66), not from the database, so this is developer-controlled, not editor-controlled. LOW today, but it is a loaded gun: the moment anyone routes editor text through markdown_to_html, it becomes stored XSS. Add filter_html: true.

Same file, app/models/folio/help_document.rb:89 and :109 build shell commands by interpolating path (backticks and system), and line 49 uses YAML.load_file rather than safe_load_file. All three read from doc/help/index.yml, a deploy-time file. LOW, same trust boundary.


4. Injection and IDOR

4.1 SQL injection — none found, and the one risky spot is correctly guarded

I swept for find_by_sql, execute(, string-interpolated where/order, and order(params…). Results:

LocationPatternVerdict
app/controllers/folio/console/api/autocompletes_controller.rb:97scope.where("#{field} ILIKE ?", "%#{q}%")Safe. field is gated by klass.column_names.include?(field) at line 78, and q is bound. This is the right way to do it.
app/models/concerns/folio/publishable.rb:93,119,124,209,216, featurable.rb:45where("#{table_name}.published = ? …", …)Safe — table_name is code-derived, values bound.
app/models/concerns/folio/file/has_usage_constraints.rb:71,117,196sanitize_sql_array([...])Correct use.
app/models/concerns/folio/has_attachments.rb:160connection.execute(update_manager.to_sql)Arel-built. Safe.
app/models/folio/file.rb:146-148where("folio_files.file_name ILIKE ?", like) with sanitize_sql_like at line 140Correct, including the LIKE-wildcard escape.
app/models/folio/site_user_link.rb:12,16,20,24where.not("roles ? :role", role: …)Named binds into a JSONB operator. Safe.
app/cells/folio/console/group_by_day_header_cell.rb:13,15where("#{model[:attribute]} > ?", …)Column name from cell options, not from params. Safe as called today.

No SQL injection found. This is above average for a 54k-line Rails engine and deserves credit.

4.2 CRITICAL — order_scope is send-ed onto an ActiveRecord::Relation with only respond_to? as a guard

SeverityCRITICAL (mass deletion)
StatusPROVED for delete_all/destroy_all; SUSPECTED for unscoped

app/controllers/folio/console/api/autocompletes_controller.rb repeats this block four times — #show (lines 46-51), #field (101-106), #selectize (165-170), #select2 (217-222), #react_select (304-309, 363-368):

p_order = params[:order_scope]
...
if p_order.present? && scope.respond_to?(p_order)
  scope = scope.unscope(:order).send(p_order)      # ← arbitrary method name from params

All five are GET routes (config/routes.rb:280-285).

respond_to? is not an allow-list. ActiveRecord::Relation publicly responds to delete_all and destroy_all. Therefore:

GET /console/api/autocomplete/select2?klass=Folio::Page&order_scope=delete_all

scope at that point is Folio::Page.accessible_by(Folio::Current.ability).by_site(Folio::Current.site), so the deletion is bounded by the attacker's own ability — but that still means every page, or every file, or every lead of their own tenant, destroyed in one request. The subsequent pagy(scope, …) will raise and return 500, but the rows are already gone.

Because it is a GET and the session cookie is SameSite=Lax (config/initializers/session_store.rb:7), an <img> tag will not carry the cookie, but a link a console administrator clicks will — top-level navigation is exempt from Lax. So this is a one-click CSRF-to-mass-delete against any administrator, from any email or chat message.

SUSPECTED extension, needs one console check. ActiveRecord::Delegation forwards unknown Relation methods to the model class via method_missing, and respond_to_missing? reports them as present. If unscoped forwards, then order_scope=unscoped discards the accessible_by and by_site conditions applied on the preceding lines, and #select2/#show return paginated, page-able labels of every row of that model across every tenant — a clean cross-tenant read. #field (line 101-106) would do the same for any column name, including encrypted_password, via .pluck(field) at line 106.

Confirm in one line of rails console:

Folio::Page.all.respond_to?("unscoped")   # if true, the cross-tenant read is live
Folio::Page.all.respond_to?("delete_all") # expect true — the mass delete is live

Consequence: loss of the entire content and media catalogue for the site, with no undo (delete_all skips callbacks and audited). For an auction house mid-sale, that is the catalogue gone.

Fix: replace scope.respond_to?(p_order) with a per-model allow-list, e.g. klass.try(:allowed_order_scopes)&.include?(p_order). Five call sites.

Two lesser send patterns in the same file, both narrower and both worth tightening:

4.3 The send sweep elsewhere

app/controllers/folio/console/base_controller.rb:584-588 builds "sort_by_#{params[:sort]}" and checks respond_to? — prefix-constrained, safe.

app/models/concerns/folio/filterable.rb:15-21 does results.public_send(key, value) for each filtering param. This looks alarming, but the only caller passes filter_params (base_controller.rb:122-123), which is params.permit(:by_label_query, *index_filters_keys) — an explicit per-controller allow-list. Correctly constrained at the caller. Not a defect.

app/controllers/folio/console/api/aasm_controller.rb:5-19 does params.require(:klass).safe_constantize then record.send("#{event_name}!"). It is unscoped by site, but the event is filtered through record.allowed_events_for(Folio::Current.user) (line 11), which at app/models/concerns/folio/has_aasm_states.rb:23-29 requires user.can_now?(event.name, self) — a per-record CanCan check that fails closed when no rule matches. Good design. Residual risk, SUSPECTED: if any Auctify or Auctea model declares an ability for an AASM event without a site condition, this becomes a cross-tenant state-machine trigger — on an auction engine, that means starting, closing or reopening another house's auction. Worth checking, given the money.

4.4 IDOR summary

EndpointScoped?Authorised?Severity
GET /download/:slug (downloads_controller.rb:18,20)nono auth at allCRITICAL (§1.4)
GET/POST /console/transport/:class_name/:id (transports_controller.rb:42)noconsole access onlyCRITICAL (§2.4)
GET /console/atoms/placement_preview/:klass/:id (atoms_controller.rb:80)noconsole access onlyHIGH (§2.5)
POST /console/api/aasm/event (aasm_controller.rb:7)noper-record via allowed_events_forLOW in Folio, SUSPECTED in Auctea
GET /console/api/jw_player/video_url (jw_player_controller.rb:5)nocan_now?(:show, file) at line 7Correct
GET /console/:model/:id (base_controller.rb:506-528)by_site(allowed_record_sites)load_and_authorize_resourceCorrect
POST /console/api/current_users/console_url_ping (api/current_users_controller.rb:95-104)signed message_verifier token + can_now?(:update, record)yesExemplary — see §9

5. Mass assignment

Strong-parameter discipline across the console is good. Every folio_console_controller_for controller defines an explicit *_params method with require(...).permit(...). grep -rn "permit!" returns zero hits — there is no blanket permit anywhere in the engine.

Nested attributes are also explicitly enumerated rather than blanket-permitted: base_controller.rb:202-236 (file placements), :238-247 (private attachments), :249-262 (atoms), :264-277 (console notes), :292-318 (addresses).

Three real problems:

#LocationIssueSeverity
1app/controllers/folio/console/users_controller.rb:174site_user_links_attributes: [:site_id, …] permits an arbitrary site id. This is link (b) in the §2.2 escalation chain. roles: [] is separately gated (site_user_link.rb:46-56) but that gate is defeated by the unconditional ability rule.CRITICAL in context
2app/lib/folio/transportable/importer.rb:32@record.assign_attributes(@hash[:attributes].without(:id)) — no strong parameters at all, from a user-supplied YAML body. Any column, any value. Only :id is excluded.CRITICAL (§2.4)
3app/controllers/folio/console/api/console_notes_controller.rb:28-33klass = params.require(:target_type).safe_constantize then @target.update(params.permit(*console_notes_strong_params)). The params are allow-listed, but the target class and id come from the request — read the file in Auctea's context to confirm the target is site-scoped.MEDIUM, SUSPECTED

Positives worth naming:


6. Multi-tenancy

This is the section that matters most for a shared deployment, and it is where Folio is weakest.

6.1 There is no default scope. Tenancy is opt-in, per query.

app/models/concerns/folio/belongs_to_site.rb (32 lines, the whole concern) provides belongs_to :site with required: false (line 8), a by_site scope (line 13) and a validate_belongs_to_site validation (lines 29-31). There is no default_scope.

Every single query must remember .by_site(...). The console base class does remember it, systematically (base_controller.rb:101-103, 506-528, 547-563) — that part is well engineered. But the fourteen controllers of §2.3 sit outside that machinery, and three of them (§1.4, §2.4, §2.5) demonstrably forget it.

A default_scope would have prevented all three. Opting out where genuinely needed (unscoped) is a deliberate, greppable act; opting in everywhere is a discipline that fails the first time someone is in a hurry.

6.2 Request → site mapping falls back to the main site on an unknown Host

app/models/folio/current.rb:81-101:

def self.get_site(host: nil)
  if host.nil?
    Folio::Current.main_site
  else
    ...
    Folio::Site.find_by(domain: host) || Folio::Current.main_site   # :98

and main_site is simply Folio::Site.order(id: :asc).first (line 118) — the lowest-id row.

So a request carrying an unrecognised Host header silently resolves to whichever tenant happens to be id 1. Folio::Current.site_matches_host? (lines 58-68) and nillify_site_records (lines 50-56) do re-check the match and drop cached site records, which is a real defence and shows the authors were thinking about it — but the fallback at line 98 still runs.

This is not by itself an auth bypass, because roles are per-site (app/models/concerns/folio/has_site_roles.rb:96-98) and Folio::Ability is rebuilt per request from (user, site) (current.rb:47). A user with no link to the main site gets no rights there. MEDIUM, and it argues for pinning the Host at the reverse proxy.

6.3 CRITICAL — cross-tenant privilege escalation is proven

Written up in full at §2.2. One PATCH request promotes an acb.cz administrator to administrator of any other tenant, because app/models/folio/ability.rb:63 and :74 grant set_administrator / set_manager on Folio::Site with no condition hash, while app/models/folio/user.rb:313 evaluates that rule against the current request's ability object regardless of which site is passed in.

This answers the question the brief asked directly. One tenant can read — and write — another tenant's data. Not through a subtle race, but through a documented form field.

6.4 HIGH — the media library is shared across all tenants by default

lib/folio/engine.rb:17:

config.folio_shared_files_between_sites = true

When true, app/models/concerns/folio/files_shared_accross_sites.rb:9-17 rewrites by_site to always return where(site: Folio::Current.main_site), and a before_validation forces every uploaded file onto the main site. app/models/folio/ability.rb:98-100 then grants can :do_anything, Folio::File for that shared site.

Every console user at every auction house on the deployment sees, edits, re-tags and deletes every other auction house's images and documents. Including unpublished catalogue photography, condition reports and anything an editor filed as a Folio::File::Document.

This is a deliberate design choice for single-brand multi-site deployments. It is exactly wrong for a deployment shared by competing auction houses.

Confirm in Auctea — one grep: grep -rn "folio_shared_files_between_sites" config/ in the Auctea repo. If it is not set to false, this is live today.

6.5 Three more unscoped cross-tenant reads

LocationWhat leaks
app/controllers/folio/downloads_controller.rb:18,20every file and private attachment, unauthenticated (§1.4)
app/controllers/folio/console/transports_controller.rb:42every transportable record, as YAML (§2.4)
app/controllers/folio/console/atoms_controller.rb:80every record's atom content, drafts included (§2.5)
app/controllers/folio/console/api/autocompletes_controller.rb:46-51 etc.SUSPECTED, via order_scope=unscoped (§4.2)

6.6 What tenancy does well

filter_records_by_belongs_to_site (base_controller.rb:506-515) raises RecordNotFound — not AccessDenied — when a loaded record's site is out of scope. That is the correct choice: it does not confirm the record exists.

load_belongs_to_site_resource (lines 521-529) and load_belongs_to_site_through_resource (lines 547-563) pre-load the record through by_site so CanCanCan never sees an out-of-scope record. Nested through routes are scoped too. This is careful work.


7. Dependencies and currency

Framework core is current and well maintained.

GemVersionAssessment
rails8.0.4.1Current. Actively patched. Good.
nokogiri1.19.2Current.
loofah / rails-html-sanitizer2.25.1 / 1.7.0Current. The sanitiser stack is up to date. Good.
devise4.9.4Current line.
cancancan3.6.1Current.
audited5.8.0Current.
message_bus4.4.1Current.
pg1.6.2Current.
dragonfly1.4.1Past CVE-2021-33564 (argument injection, fixed in 1.4.0). Version itself is fine; see §1.6 for the secret.

End-of-life, unmaintained, or superseded.

GemVersionProblem
sidekiq6.5.12Sidekiq 6 is EOL. Current is 8.x. No security backports.
redis4.8.1redis-rb 4.x is EOL. Current is 5.x.
turbolinks5.2.1Archived by Basecamp. Superseded by Turbo in 2021. The brief confirms Turbolinks 5 is what acb.cz actually ships. No further fixes will ever be issued.
sassc / sassc-rails2.4.0 / 2.1.2Wraps LibSass, deprecated since 2020. sassc-rails last released 2019.
fast_jsonapi1.5Archived by Netflix in 2019. The maintained fork is jsonapi-serializer.
cells / cells-rails / cells-slim4.1.8 / 0.1.6 / 0.0.6Trailblazer Cells is effectively dormant. The Gemfile itself admits the problem: # version 0.1.0 drops Rails support and I was not able to make it work.
dropzonejs-rails0.8.5Last released 2015.
babel-transpiler0.7.0Wraps Babel 5 (2015).
slim3.0.9Slim 5 is current.
recaptcha4.13.15.x is current.
puma5.6.9, pinned < 6 (Gemfile:33)Puma 5 is EOL. Dev-group only here, so production Puma is Auctea's — but the < 6 pin is what holds Rack at 2.x.
pg_search2.3.2Behind current 2.3.x.

Rack 2 vs Rack 3. rack (2.2.22) with Rails 8. Rack 2.2 is in security-backport-only mode; Rack 3 has been current since 2022. The < 3 constraints come from rack-session (1.0.2) and rackup (1.0.1) (Gemfile.lock:575-580), transitively from the puma < 6 pin. 2.2.22 is a recent patch of the 2.x line, so there is no known open CVE — but the whole application is parked on a line that will stop receiving fixes.

Sprockets vs Propshaft. Folio hard-requires Sprockets (Gemfile:16-17, gem "sprockets", "~> 4.0") and ships a Sprockets manifest at app/assets/config/folio_manifest.js. Rails 8's default asset pipeline is Propshaft; Sprockets 4.2.2 is the final release and the project is in maintenance mode. Practically this means: no ESM, no import maps, JS transpiled through babel-transpiler (Babel 5), and any future Rails upgrade will have to carry a maintenance-only pipeline. It is not a security hole today. It is a guarantee of an expensive migration later, and it is why the site ships Turbolinks rather than Turbo.

Ruby. .ruby-version pins 3.3.3 (released June 2024). Ruby 3.3 has since shipped multiple security patches (REXML DoS chain, cgi/uri issues). Pinning a two-year-old teeny version is a live gap. Confirm the production Ruby with ruby -v on the Auctea host — the engine's .ruby-version need not match the app's.

Vendored JavaScript is invisible to any scanner. package.json lists exactly two dependencies (bootstrap and standard). Everything else is checked into vendor/assets/javascripts/ with no version manifest and no lockfile entry:

dropzone-6-0-0-beta-2.js   ← a beta, shipping in production
selectize.js  daterangepicker.min.js  turbolinks.js  stimulus.umd.js
tom-select.complete.js  photoswipe.esm.folio.js ("folio" = locally patched)
tempus-dominus.js  cookieconsent.js  slideout.js  popper.min.js
vendor/assets/bower_components/cleave.js   ← Bower, dead since 2017

Neither npm audit nor Dependabot can see any of these. The .folio suffix on the PhotoSwipe build means it has been locally modified and can never be cleanly updated. MEDIUM, and it is a permanent tax rather than a one-off fix.


8. Maintenance signals

Commit cadence — healthy and current.

PeriodCommits/month
2025-07 … 2025-09178, 172, 207
2025-10 … 2025-12102, 44, 33
2026-01 … 2026-0669, 42, 45, 50, 40, 51
2026-07 … 2026-09 (partial)52, 14, 12

6 749 commits total. Commits landing the day of HEAD. Folio is not abandoned — the contrast with auctify (7 commits since mid-2022) is stark.

Contributor concentration — the main risk.

AuthorAll-timeLast 12 months
Petr Marek5 197 (77 %)426 (~60 %)
dedekm242—
Petr Mlčoch22828
Martin Dedek21915
Filip Ornstein17326
dependabot[bot]7122

Roughly a dozen humans have touched it in the last year, so it is not a solo project — but one person is three-quarters of the codebase and still the majority of current work. For a system holding an auction house's catalogue, that is a bus factor of one at the vendor (Sinfin). The commercial mitigation is a support contract with named escalation, not a code change.

dependabot[bot] appearing 71 times all-time and 22 times in the last year confirms automated dependency updates are running — though §7 shows they are not reaching the EOL gems, and they cannot see vendor/assets/javascripts/ at all.

CI — exists, but unverifiable from here. Jenkinsfile defines one stage that runs sh '/var/lib/jenkins/test_rails.sh' and posts the result to Slack. The script lives outside the repository, so I cannot confirm what actually runs — whether it is the full suite, whether it gates merges, or whether failures block anything. There is no .github/ directory, no GitHub Actions, no dependabot.yml in the tree.

Tests — substantial. 484 test files, 34 545 lines, against 53 903 lines of application code. That is a ~0.64 test-to-code ratio, well above average for a Rails engine. Coverage exists for the specific endpoints in this report (test/controllers/folio/console/api/autocompletes_controller_test.rb, transports_controller_test.rb, downloads_controller_test.rb). But the tests assert the happy path, not the negative path — downloads_controller_test.rb:8-13 confirms an unauthenticated download succeeds, and transports_controller_test.rb only ever signs in as superadmin. There is no cross-tenant test anywhere: grep -rn "other_site\|second_site" across test/ produces no authorisation-boundary cases.

No security policy. No SECURITY.md, no .github/SECURITY.md. There is no documented way to report a vulnerability in Folio and no stated response time. For an adopter running real money, that is a contractual gap to close.

Changelog discipline — genuinely good. CHANGELOG.md follows Keep a Changelog with Added / Changed sections, and the entries are specific and useful ("Folio::Console::PrivateAttachmentsFieldsComponent accepts title_input: false…"). UPGRADING.md exists and is referenced from changelog entries that contain breaking changes. This is better than most commercial Rails engines.

Versioning is inconsistent. lib/folio/version.rb says 7.7.2, but git tag lists only v7.4.1 and v7.6.2. Releases are not consistently tagged, so pinning to a reviewed commit means pinning a SHA, not a version.


9. Where the code is good

I want this section to carry the same weight as the findings, because it changes the remediation picture: these are not the habits of a careless team, and most findings above are localised mistakes in an otherwise sound design.

  1. Content type is derived from the bytes. config/initializers/dragonfly.rb:67-71 shells file --brief --mime-type. The client's Content-Type is never trusted. Most CMSes get this wrong.
  1. Sanitisation is default-deny and applies engine-wide. Folio::HtmlSanitization::Model is included in Folio::ApplicationRecord itself (app/models/folio/application_record.rb:9). The default config is :string, which strips all markup (app/lib/folio/html_sanitization/sanitizer.rb:124-126). Unknown configs raise ArgumentError (line 76) rather than passing through. Allowing raw HTML requires typing :unsafe_html, which is greppable.
  1. sanitize_href is a clever, correct construction. sanitizer.rb:28-49 round-trips a candidate URL through Rails' own sanitiser inside a throwaway <a> and rejects it if the attribute was stripped — instead of hand-rolling a javascript: regex that will eventually be bypassed.
  1. No SQL injection, and the one dynamic column name is properly gated. autocompletes_controller.rb:97 interpolates field only after klass.column_names.include?(field) (line 78), with the value bound. app/models/folio/file.rb:140 uses sanitize_sql_like before building a LIKE pattern — an escape most people forget.
  1. No permit! anywhere. Zero hits across 54k lines. Nested attributes are enumerated field by field. Permit lists are made conditional on the caller's rights where it matters (users_controller.rb:168-172).
  1. The presence API is a model of how to do this. app/controllers/folio/console/api/current_users_controller.rb:95-104 verifies a Rails.application.message_verifier token carrying {type, id, url}, rejects it if the URL does not match the request (line 81), checks the class really is an ActiveRecord::Base descendant (line 86), and still re-checks can_now?(:update, record) before rendering (line 68). The comments at lines 78-80 and 91-94 explain why each guard exists. Whoever wrote this understood the threat model.
  1. AASM events fail closed. app/models/concerns/folio/has_aasm_states.rb:23-29 gates every state transition on user.can_now?(event.name, self), so an event with no matching ability rule is simply unavailable. That is the right default for a state machine that will drive auction lifecycle in Auctea.
  1. Devise is configured properly. config/initializers/devise.rb: paranoid = true (line 96, no user enumeration), stretches = 11 (line 126), lock_strategy = :failed_attempts with maximum_attempts = 5 (lines 243, 257), timeout_in = 30.minutes (line 237), reconfirmable = true (line 206), expire_all_remember_me_on_sign_out = true (line 216). Session cookie is secure, httponly, SameSite=Lax, 12-hour expiry (config/initializers/session_store.rb:3-7). The SameSite=None exception is narrowly scoped to the single Apple OAuth callback path (config/initializers/omniauth.rb:14-16) rather than applied globally.
  1. The console's own record loading is properly tenant-scoped, including through nested parent routes (base_controller.rb:506-563), and it raises RecordNotFound rather than AccessDenied so it does not confirm existence.
  1. jw_player_controller.rb:5-7 does an unscoped find and then immediately can_now?(:show, file) — the pattern §2.4 and §2.5 should have followed, in the same codebase, three files away.
  1. **Comments explain why.** app/models/folio/file.rb:448-456 on numeric slug collisions; base_controller.rb:609-616 on canonical presence URLs; file.rb:142-144 on tsvector behaviour with dotted filenames; the folio-embed-dist.html origin check. These are notes from someone who debugged the problem and wrote down the conclusion.
  1. CHANGELOG.md and UPGRADING.md are maintained and specific.

10. Latent bugs (not security, worth reporting)


11. The three findings that matter most

For an auction house whose bidders log in and whose hammer prices run into millions of CZK, ranked by realistic loss.

1. Cross-tenant privilege escalation via site_user_links_attributes — CRITICAL

app/models/folio/ability.rb:63 and :74 (unconditional can :set_administrator, Folio::Site) + app/models/folio/user.rb:313 (ability not rebuilt for the target site) + app/controllers/folio/console/users_controller.rb:174 (site_id freely permitted) + app/models/concerns/folio/has_site_roles.rb:49 (Folio::Site.find on an arbitrary id).

One PATCH request makes an administrator of any tenant an administrator of every tenant on the deployment. On a shared Auctea installation, a competing auction house's staff can read acb.cz's consignor list, reserve prices and bidder identities, and can alter lot data. It is bidirectional, it requires no exotic technique, and it leaves an ordinary-looking audit trail.

Fix in Folio: change ability.rb:63,74 to can :set_administrator, Folio::Site, { id: site.id }. One line each. Fix Auctea can apply today without Sinfin: override Folio::Ability#folio_rules to re-scope both rules. Verify: attempt the PATCH in staging as a non-superadmin administrator.

2. Stored XSS on the site's own origin via the embed system — CRITICAL

app/lib/folio/embed.rb:99-101 (HTML stored verbatim) → data/embed/dist/folio-embed-dist.html:282-296 (innerHTML, then <script> tags deliberately re-created and appended to document.head) → app/components/folio/embed/box_component.js:99 (same-origin iframe, no sandbox attribute) → no CSP anywhere in the engine.

Any editor account — or any attacker who phishes one editor — gets arbitrary JavaScript on www.acb.cz for every visitor, including logged-in bidders during a live sale. With message_bus long-polling driving the bidding UI on that same origin, script injected here sits inside the bidding client: it can alter a displayed price, modify a bid before submission, or exfiltrate sessions. The loss is a contested or manipulated sale plus the legal exposure that follows.

Fix: serve /folio/embed from a separate origin. app/lib/rack/folio/embed_middleware.rb:13 matches a fixed path; box_component.js:97 builds the src. Moving that to a dedicated hostname contains every embed to a throwaway origin and changes nothing functionally. Add sandbox="allow-scripts" as a second layer, and set a real CSP on the main site.

3. /download/:slug — unauthenticated, unscoped, enumerable — CRITICAL

app/controllers/folio/downloads_controller.rb:8,18,20 — inherits ActionController::Base, no authentication, no site scope, and friendly.find resolves numeric params to the primary key by design (app/models/folio/file.rb:448-456).

A stranger with curl walks /download/1 … /download/N and retrieves every Folio::File — and, on ids where no file exists, every Folio::PrivateAttachment, which has no format validation, no site column, and no private-ACL storage headers (contrast session_attachment/base.rb:18). Unpublished catalogues, condition reports, provenance documents, consignment paperwork. No rate limiting exists anywhere in the engine to slow it down.

Fix: add authenticate_user! and a site scope, or at minimum reject numeric slugs so only the unguessable slug or hash_id resolves — and raise Folio::PrivateAttachment.hash_id_length above the current default of 4 (app/models/concerns/folio/has_hash_id.rb:40). Verify in one command: curl -sI https://www.acb.cz/download/1.


12. Verdict

Security: 4 / 10. The defensive foundations are better than average — byte-level MIME detection, an engine-wide default-deny sanitiser, zero SQL injection, zero permit!, a properly hardened Devise, and a console record-loading path that is genuinely tenant-aware. But tenancy is opt-in rather than enforced, CanCanCan's check_authorization is switched off, and three controllers plus one ability rule break the tenant boundary outright. For a single-tenant CMS, 6/10. For competing auction houses sharing one deployment, the isolation does not hold, and that is the question that was asked.

Maintainability: 6 / 10. Actively developed with commits landing today, a disciplined changelog, an UPGRADING.md, and a substantial test suite (0.64 test-to-code). Against that: 77 % of the code from one author, CI behind an unversioned shell script, no security policy, inconsistent release tagging, a dozen EOL dependencies, ~20 vendored JS files invisible to any scanner, and a hard dependency on Sprockets when Rails 8 has moved to Propshaft — which is a migration the house will pay for eventually.

Recommendation: keep Folio, but not on a shared deployment and not without a support contract. The three critical findings are all small, local fixes in code the authors clearly understand — they are oversights, not architecture. What is architectural is §6.1: tenancy has no enforced floor. Until a default_scope exists, every new controller is a chance to leak, and the next one will not be found by this review.

Minimum before the next sale:

  1. Fix §2.2 in Auctea's Folio::Ability override. Today. It does not require Sinfin.
  2. Check DRAGONFLY_SECRET is set (§1.6). Five minutes, and if it is unset the severity of everything else changes.
  3. Check folio_shared_files_between_sites (§6.4). One grep.
  4. curl -sI https://www.acb.cz/download/1 (§1.4).
  5. Move /folio/embed to a separate origin (§3.2) and put a CSP in front of the site.
  6. Replace the five respond_to?-guarded send calls in autocompletes_controller.rb with allow-lists (§4.2) before someone emails an administrator a link.
  7. Get a security-contact and response-time clause into the Sinfin contract. There is no SECURITY.md and no other channel.