auctify engineTarget: inputs/repos/auctify, HEAD a1b60f0 (2024-11-15). First commit 2021-02-08. Adopter: Aukční síň ACB / "Auctea" (www.acb.cz), production, hammer prices in millions of CZK. Scope: security of the auctify engine only. folio is read here only where auctify depends on it. Method: every file under app/controllers, app/models, app/services, app/jobs, app/helpers, app/views, lib/, config/routes.rb, the gemspec, Gemfile.lock, and the full 285-commit history.
What I cannot see. Auctea is closed. auctify is a library: several of its worst defects are only exploitable if Auctea mounts the engine's routes and does not override the controllers. Everything below is marked PROVED (read in this repository) or SUSPECTED (requires one check against Auctea, which I name).
Read this first — the repo you are auditing is probably not the code you are running.
| Fact | Evidence |
|---|---|
auctify pins rails "~> 6.1.4" | auctify.gemspec:34 |
folio HEAD pins rails "~> 8.0" | folio/folio.gemspec:72 |
folio HEAD's ApiControllerBase calls skip_before_action :handle_crossdomain_devise with no raise: false; Auctify::ApplicationController has no such callback, so the class raises ArgumentError at boot | folio/app/controllers/concerns/folio/api_controller_base.rb:11, auctify/app/controllers/auctify/application_controller.rb:4 |
auctify's lockfile pins folio at revision c743af4b (2022) | Gemfile.lock:5 |
The only authorisation gate in the operator API calls current_account, a method that no longer exists anywhere in folio HEAD (it was Folio::Account, since merged into Folio::User) | app/controllers/auctify/api/v1/console/bids_controller.rb:27; grep -rn current_account over folio HEAD returns nothing |
auctify at HEAD cannot be installed alongside folio at HEAD. Auctea therefore runs either a 2022-era folio (three years of unpatched CMS) or a private fork of auctify. Before acting on any finding here, confirm which: bundle list | grep -E "auctify|folio" and bundle info auctify on the production host. If it is a fork, this review covers the upstream only and the fork needs its own diff review.
config/routes.rb:3-23 defines these inside the engine. The reference host app mounts the whole engine at / (test/dummy/config/routes.rb:10), which is what makes the paths below reachable.
| Path | Action | Authenticated? | Authorised? | Object scoped to actor? |
|---|---|---|---|---|
GET /auctify/bids | bids#index | no | no | no — Bid.all (app/controllers/auctify/bids_controller.rb:11) |
GET /auctify/bids/:id | bids#show | no | no | no — Bid.find(params[:id]) (:56) |
POST /auctify/bids | bids#create | no | no | no — Bid.new(bid_params) (:29) |
PATCH /auctify/bids/:id | bids#update | no | no | no (:40) |
DELETE /auctify/bids/:id | bids#destroy | no | no | no (:49) |
GET/POST/PATCH/DELETE /auctify/bidder_registrations[/:id] | all 7 | no | no | no — BidderRegistration.all / .find(params[:id]) (app/controllers/auctify/bidder_registrations_controller.rb:11,59) |
GET /auctify/sales | sales#index | no | no | partly — published.not_sold, unless ?list_all=1 (app/controllers/auctify/sales_controller.rb:68-73) |
POST/PATCH/DELETE /auctify/sales[/:id] | sales#create/update/destroy | no | no | no (:29,40,49) |
GET /auctify/api/v1/auctions/:id | auctions#show | no | no | no — Auction.find(params[:id]), no published scope (app/controllers/auctify/api/v1/auctions_controller.rb:38) |
POST /auctify/api/v1/auctions/:id/bids | auctions#bids | no explicit check; relies on current_user being non-nil | no | actor is current_user — correct; auction is not scoped (:50-54) |
DELETE /auctify/api/v1/console/bids/:id | console/bids#destroy | yes — current_account present | presence only, no ability check (app/controllers/auctify/api/v1/console/bids_controller.rb:26-28) | n/a (global operator) |
/auctify/sales_packs/* | all 7 | no | no | no (app/controllers/auctify/sales_packs_controller.rb) — not routed by the engine; the host app is expected to route it (test/dummy/config/routes.rb:11) |
app/controllers/auctify/api/v1/base_controller.rb:9 is the author's own admission: # TODO handle authorization here if needed. It was never done.
PROVED in code; SUSPECTED reachable in production.
app/controllers/auctify/bids_controller.rb, bidder_registrations_controller.rb and sales_controller.rb are unmodified rails generate scaffold output. They have no before_action :authenticate_user!, no authorize!, no can?, no ownership scoping — grep -rn "authenticate_user\|authorize\|can?" app/ returns nothing outside the console controller. They ship in the gem (auctify.gemspec:29 packages all of app/) and the engine routes them (config/routes.rb:5-7).
Concrete attack, if the engine is mounted as in the reference app:
# 1. Read every bid ever placed, with prices and registration ids
curl https://www.acb.cz/auctify/bids
# 2. Insert a winning bid for someone else's registration, at any price,
# bypassing BidsAppender entirely — no minimum increment, no auction-state
# check, no "you cannot overbid yourself", no closing time
curl -X POST https://www.acb.cz/auctify/bids \
-d 'bid[registration_id]=<id from step 1>&bid[price]=1000000'
# 3. Or delete the real winning bid
curl -X DELETE https://www.acb.cz/auctify/bids/<id>
Auctify::Bid validates only two things — price <= max_price and rounding (app/models/auctify/bid.rb:13-14). Every auction rule lives in Auctify::BidsAppender, which BidsController#create never calls. A bid written through this path is indistinguishable from a real one in the database.
BidderRegistrationsController permits aasm_state (:64), so an attacker registers themselves as an approved bidder for any lot in one request. SalesController#update permits published (:65), and Auction#published= runs accept_offer then start_sale (app/models/auctify/sale/auction.rb:129-136) — one unauthenticated PATCH opens a lot for bidding before the auction house intended.
Consequence: the hammer price of any lot can be set by an anonymous HTTP request. For a house selling at millions of CZK per lot this is the loss of the entire lot value per incident, plus the cost of voiding the auction, plus §1811 ObčZ / consumer-protection exposure for a published result that was not produced by bidding.
Confirm in 30 seconds: curl -s -o /dev/null -w '%{http_code}\n' https://www.acb.cz/auctify/bids. 200 or 422 (CSRF) means reachable. 404 means Auctea does not mount the engine's non-API routes, and this drops to LOW (dead code in a dependency).
Fix: delete resources :bids, :bidder_registrations, :sales from config/routes.rb:5-7 in your fork, or mount the engine behind a route constraint that allows only api/v1/auctions. Do not rely on Auctea overriding the controllers — the routes are what make them reachable.
PROVED.
app/controllers/auctify/api/v1/console/bids_controller.rb:26-28:
def api_authenticate_account!
fail CanCan::AccessDenied if current_account.blank?
end
Line 7 carries the comment # Folio::Console::Api::BaseController — the author knew the correct parent and did not use it. folio HEAD's real console API base does a genuine ability check (folio/app/controllers/folio/console/api/base_controller.rb:19-21: can_now?(:access_console, site)). auctify copied folio's 2022 pattern (identical line at folio@c743af4b:app/controllers/folio/console/api/base_controller.rb:19) and never followed it forward.
In fairness: in the 2022 ability model every console role — manager, administrator, superuser — got can :manage, :all (folio@c743af4b:app/models/folio/console_ability.rb:9-20), and deactivated accounts cannot sign in (active_for_authentication? checks is_active). So the presence check was, at the time, equivalent to the ability check. The gap is that it can never become less than equivalent: if Auctea ever adds a limited role (a cataloguer, an accountant, a photographer), that role silently gains the power to cancel bids.
What "cancel a bid" means here: Bid#cancel! (app/models/auctify/bid.rb:23-27) sets cancelled: true and calls auction.recalculate_bidding!, which rewrites current_price and the winner. Auctify::Bid is not audited (grep -rn audited app/ lib/ returns nothing), so the only trace is the cancelled flag and updated_at. One operator can, alone and unlogged, remove the winning bid from a closed lot and hand it to the underbidder.
Consequence: insider fraud on a single lot, at the full spread between top bid and underbid. No after-the-fact evidence beyond a boolean.
Also PROVED, and to Auctea's credit: test/controllers/auctify/api/v1/console/bids_controller_test.rb:73-99 explicitly asserts that a signed-in bidder (lucifer) gets 401 on DELETE, and only an account gets 200. The boundary between bidder and operator is tested. The gap is within the operator population.
Fix: inherit from Folio::Console::Api::BaseController, or add fail CanCan::AccessDenied unless can?(:manage, Auctify::Bid). Add audited to Auctify::Bid, or write a cancelled_by_id / cancelled_at column. For an auction house, cancelling a winning bid should require a recorded actor and a reason.
PROVED.
auctions_controller.rb:17-34 never checks that anybody is signed in. The actor is taken from current_user (:50-54) — which is the right design — but nothing asserts current_user is present.
Trace for an anonymous POST /auctify/api/v1/auctions/:id/bids with terms_confirmation=1: registration_params → find_by(bidder: nil) → nil → { bidder: nil } → Bid#bidder= adds an error and sets @bidder = nil (app/models/auctify/bid.rb:51-55) → bid! fails cleanly → then line 26 calls store_confirmations_checkboxes, which calls current_user.bidder_registrations on nil → NoMethodError → caught by rescue_from StandardError → the response body contains {"title":"NoMethodError","detail":"undefined method ..."} (folio/app/controllers/concerns/folio/api_controller_base.rb:20-39).
So: the bid is correctly refused (good), but every anonymous attempt costs a 500 and hands the attacker the exception class and message. rescue_from StandardError returning e.message to the client applies to every unhandled error in these controllers — an ActiveRecord::StatementInvalid would return SQL.
Consequence: reconnaissance aid and error-budget noise, not direct money loss. LOW on its own; MEDIUM because it proves there is no authentication gate and because the leak channel is generic.
Fix: before_action :authenticate_user!, only: [:bids], and guard store_confirmations_checkboxes with return if current_user.blank?.
PROVED. auctions_controller.rb:66-79:
bidder_regs.update_all(atts) # :71
if atts[:confirmed_sales_pack_terms]
current_user.bidder_registrations.for_pack(bidder_regs.last.auction.pack)
.where(confirmed_sales_pack_terms: false)
.update_all(confirmed_sales_pack_terms: true) # :74-76
end
Three problems, all provable from these lines:
terms_confirmation=1 checkbox on lot 12 writes confirmed_sales_pack_terms = true to the bidder's registrations for all lots in the sales pack (for_pack, app/models/auctify/bidder_registration.rb:20).
:29 calls it before rendering the 400). A rejected bid stillmanufactures a terms confirmation.
update_all bypasses validations, callbacks, updated_at and any audit trail. The record of consent is abare boolean — no timestamp, no version of the terms text, no IP.
Consequence: if a buyer disputes a purchase ("I never accepted the auction conditions for lot 37"), the house's only evidence is a boolean that the code demonstrably sets from a click on a different lot. Under Czech consumer law the burden of proving that terms were presented and accepted sits with the trader. This is a weak position in a dispute over a lot worth millions.
Fix: record consent per registration with confirmed_terms_at, the terms version, and the lot it was given on. Never fan it out across the pack. Do not record consent for a rejected bid.
Every permit call in the repository:
| File:line | Permitted | Dangerous? |
|---|---|---|
app/controllers/auctify/api/v1/auctions_controller.rb:42 | :max_price, :price | No. No actor, no state, no id. The actor is merged from current_user at :46, and bid_params is merged first so it cannot override. This is the one controller that gets it right. |
app/controllers/auctify/bids_controller.rb:61 | :registration_id, :price, :max_price | CRITICAL. registration_id is the actor. Anyone can bid as anyone. See F-01. |
app/controllers/auctify/bidder_registrations_controller.rb:64-65 | :bidder_id, :bidder_type, :auction_id, :aasm_state, :submitted_at, :handled_at | CRITICAL. bidder_id/bidder_type is the actor; aasm_state is the approval decision. Self-approval and impersonation in one POST. See F-01. |
app/controllers/auctify/sales_controller.rb:65 | :seller_auctify_id, :buyer_auctify_id, :item_auctify_id, :published | HIGH. buyer_auctify_id sets who bought the lot; published opens the sale (auction.rb:129-136). Also the injection sink in F-06. |
app/controllers/auctify/sales_packs_controller.rb:61-72 | title, description, position, slug, place, published, sales_interval, sales_beginning_hour/minutes, start_date, end_date | MEDIUM. No actor or price field, but published and the date window are operator decisions. |
Not reachable by mass assignment, and worth saying so: price on a sale (current_price, sold_price, offered_price, reserve_price), aasm_state on a sale, winner, current_winner, created_at, and cancelled on a bid are not in any permit list. The model-level guards behind them are real (see §11).
Every find(params[:id]) in the engine:
| File:line | Scoped? | Severity |
|---|---|---|
app/controllers/auctify/api/v1/auctions_controller.rb:38 — Auctify::Sale::Auction.find(params[:id]) | no — no published scope, no pack scope | MEDIUM |
app/controllers/auctify/bids_controller.rb:56 | no | CRITICAL (subsumed by F-01) |
app/controllers/auctify/bidder_registrations_controller.rb:59 | no | CRITICAL (subsumed by F-01) |
app/controllers/auctify/sales_controller.rb:60 — scoped_sales.find(params[:id]) | yes (published.not_sold) — but ?list_all=1 removes the scope (:70) | HIGH (subsumed by F-01) |
app/controllers/auctify/api/v1/console/bids_controller.rb:23 | no — operator is global, acceptable | LOW |
app/jobs/auctify/bidding_closer_job.rb:10, bidding_is_close_to_end_notifier_job.rb:12 | n/a, internal job argument | — |
PROVED. auctions_controller.rb:38 finds by primary key with no published filter. The public site uses slugs (Folio::FriendlyId, app/models/auctify/sale/base.rb:6), so lot ids are not otherwise exposed — but they are sequential integers. GET /auctify/api/v1/auctions/1..N enumerates every auction row that has ever existed, including lots in offered/accepted state that have not been published.
What comes back is a rendered cell owned by Auctea (:56-64), so the exact payload is SUSPECTED — confirm by requesting the id of a known-unpublished lot. The serializer that would be used by folio's generic path exposes current_price, current_minimal_bid, currently_ends_at, ends_at, open_for_bids? and the current winner's id, auctify_id and to_label (app/serializers/auctify/sale/auction_serializer.rb:6-20) — i.e. the winning bidder's display name is a public field. For an art auction house, "who is bidding on what" is commercially sensitive; consignors and competitors both want it.
Fix: Auctify::Sale::Auction.published.find(params[:id]), and use the slug, not the id.
No SQL injection found. This is a genuine positive. Every where with a fragment uses bind parameters (app/models/auctify/sale/base.rb:39,43; app/jobs/auctify/ensure_auctions_closing_job.rb:9). There is no find_by_sql, no execute(, no order(params[...]), no string interpolation into any query. The one raw Arel construction (app/models/auctify/sale/base.rb:51-64) is built from arel_table columns only, with no user input. pg_search_scope (app/models/auctify/sales_pack.rb:36-39) is parameterised by the gem.
constantize on an unfiltered request parameterPROVED in code; reachability tied to F-01.
app/concerns/auctify/behavior/base.rb:12-17:
def object_from_auctify_id(auctify_id)
return nil if auctify_id.blank?
klass, id = auctify_id.to_s.split("@")
klass.constantize.find(id) # :16
end
Reached from app/models/auctify/sale/base.rb:80-83, which defines seller_auctify_id=, buyer_auctify_id= and item_auctify_id= — all three are in sale_params (app/controllers/auctify/sales_controller.rb:65). So sale[buyer_auctify_id]=Anything@1 calls "Anything".constantize.
There is no allow-list. Auctify::Behaviors.registered_classes_as exists (app/models/auctify/behaviors.rb:23-25) and is used by the view helper (app/helpers/auctify/sales_helper.rb:8) to build the dropdown — but the setter never consults it.
This is not RCE: constantize resolves a constant, it does not call it, and .find then fails on anything that is not an ActiveRecord class. The concrete impact is (a) forcing autoload of arbitrary application constants, (b) NameError → 500 → exception message returned to the client (F-03's leak channel), and (c) assigning a polymorphic buyer of an unintended class, which then bypasses valid_buyer's intent (app/models/auctify/sale/base.rb:135-138 only checks the record reloads).
Fix: klass = auctify_id.to_s.split("@").first; reject unless Auctify::Behaviors.registered_classes_as(behavior).map(&:name).include?(klass).
The other constantize/send sites are safe and I checked each: app/models/auctify/sale/auction.rb:297 (class_name.constantize) reads from Auctify.configuration.autoregister_as_bidders_all_instances_of_classes, set in an initializer, never from a request. app/models/auctify/behaviors.rb:16 runs at class definition time from a literal. app/models/auctify/sale/base.rb:82,86 interpolates a fixed array [:seller, :buyer, :item], not input.
Clean. grep -rn "html_safe\|raw \|sanitize" app/views app/helpers returns nothing. All 21 view templates use escaped ERB output. The helpers (app/helpers/auctify/*.rb) contain no markup generation — sales_helper.rb builds a plain array for options_for_select, which Rails escapes.
One note, not an XSS: app/views/auctify/api/v1/console/bids/show.html.erb renders <%= @bid.to_json %>, dumping the whole record. ERB escapes it, so it is not injectable, but it is an unnecessary data dump. Both console views are dead — config/routes.rb:18 routes only: %i[destroy], and #index never assigns @bids. (routes.txt:106-113 still lists all seven console routes; that checked-in file is a stale artifact from an older routes.rb and should not be trusted.)
No protect_from_forgery is skipped anywhere in auctify. grep -rn "protect_from_forgery\|skip_forgery" app/ returns one line: app/controllers/auctify/application_controller.rb:5, protect_from_forgery with: :exception.
Auctify::Api::V1::BaseController < ApplicationController (app/controllers/auctify/api/v1/base_controller.rb:6) resolves through Ruby's nesting to Auctify::ApplicationController, so the API inherits the exception-raising CSRF filter. Folio::ApiControllerBase does not turn it off. Confirmed by reading both the 2022 and HEAD versions of that concern.
So a cross-site <form> cannot place a bid. This is correct and I want to be clear that it is.
Two caveats:
Auctify::ApplicationController. Confirm with Auctify::Api::V1::AuctionsController.ancestors.include?(ActionController::RequestForgeryProtection) and Auctify::Api::V1::AuctionsController.forgery_protection_strategy in a production console.curl. Everything in §8 stands.No secrets, in the tree or in the history. This is a clean result and I checked it properly:
grep -rniE "password|secret|api[_-]?key|token|BEGIN RSA|AKIA" over all .rb/.yml/.erb returns only test fixtures (test/fixtures/users.yml:6,11,16 — Devise digests of joke passwords), commented seed data (test/dummy/db/seeds.rb:3-4), and schema column names.test/dummy/config/database.yml ships a production block with empty username/password/host — placeholders, not credentials.git log --all --diff-filter=D --name-only filtered for .env, master.key, credentials, .pem, .p12 returns nothing — no credential was ever committed and later removed. .env.example is the only env-shaped file ever tracked.config/credentials.yml.enc, no master.key.Data that leaves the server and arguably should not (both covered above): AuctionSerializer exposes the current winner's to_label — a real person's display name — on a public endpoint (app/serializers/auctify/sale/auction_serializer.rb:13-20, F-05). And rescue_from StandardError returns e.message to the client on any unhandled error (folio/app/controllers/concerns/folio/api_controller_base.rb:20-39, F-03).
app/serializers/auctify/sale/auction_serializer.rb:16 will itself raise NoMethodError on winner.id whenever an auction has no current winner — a 500 with a leaked message for any lot with no bids, if that serializer is on a live path. LOW; auctions_controller.rb:56 overrides render_record with a cell, so it may not be.
There is no rate limiting anywhere. No rack-attack in auctify.gemspec or Gemfile.lock; no throttle, no rate_limit in either repository (grep -rni "rack.attack\|throttle\|rate_limit" over auctify and folio returns nothing). No captcha on the bidding path (invisible_captcha and recaptcha are folio dependencies used on its own forms, not on Auctify::Api::V1::AuctionsController). No per-bidder bid counter, no cooldown, no monotonic bid-sequence check.
This matters more than usual here because of what each bid does.
max_price alongside pricePROVED.
app/services/auctify/bids_appender.rb:151-162:
def check_price_maximum
return if bid.with_limit? # :152 <-- the hole
maximum = Auctify.configuration.maximal_increase_of_price_proc.call(auction)
return if maximum.blank?
if maximum < bid.price
bid.errors.add(:price, :too_high_price_increase, ...)
end
end
This guard was added on 2024-11-05 in commit c8f50ad, "adding Auctify.configuration.maximal_increase_of_price_proc to set ceil for bid price; close #450" — i.e. it exists because a runaway price already happened in production. It is the second-newest change in the repository.
Line 152 exempts any bid that carries a max_price. Both fields are permitted (app/controllers/auctify/api/v1/auctions_controller.rb:42). set_price_for_limit_bid only computes a price when price is blank (bids_appender.rb:47), so a bid with both fields keeps the attacker's price verbatim and skips the ceiling. The only validation left is price <= max_price (app/models/auctify/bid.rb:42).
Attack, by an approved bidder, in one request:
POST /auctify/api/v1/auctions/<id>/bids
bid[price]=9999999999&bid[max_price]=9999999999&terms_confirmation=1
Walk it through: check_price_minimum passes (the price is far above the minimum). check_price_maximum returns at line 152. check_same_bidder, check_auction_state pass. solve_winner → increase_bid_price or solve_limits_fight (:182-204) leaves new_bid.price at the attacker's figure. update_auction! → auction.succesfull_bid! sets current_price to it (app/models/auctify/sale/auction.rb:173-182). The lot's current price is now 9 999 999 999,99 CZK, publicly displayed, and the attacker is the winner.
The test suite does not cover this. test/services/auctify/bid_appender_test.rb:530-563 exercises the ceiling only with max_price: nil on every case.
9_999_999_999.99 is the hard limit only because the column is decimal(12,2) (app/models/auctify/bid.rb:83); there is no application-level cap on max_price at all.
Consequence: any approved bidder can destroy a live lot with one request. The auction must be stopped and re-run; the displayed result is false while it stands; and if the house's terms bind the top bidder, the house must either enforce a ten-billion-crown sale it cannot collect or publicly void it. The house already hit a milder version of this once — that is what issue #450 was.
Fix: move the ceiling check above the with_limit? guard and apply it to max_price as well:
def check_price_maximum
maximum = Auctify.configuration.maximal_increase_of_price_proc.call(auction)
return if maximum.blank?
bid.errors.add(:price, :too_high_price_increase, ...) if bid.price.present? && maximum < bid.price
bid.errors.add(:max_price, :too_high_price_increase, ...) if bid.max_price.present? && maximum < bid.max_price
end
Also add a database check constraint on auctify_bids.price and .max_price.
increase_price_to: two colluding accounts hang a workerPROVED for the default configuration; SUSPECTED for Auctea's real bid ladder.
app/services/auctify/bids_appender.rb:229-239:
def increase_price_to(overcome:, ceil:)
running_price = current_price
while running_price <= overcome # :233
running_price = increase_price(running_price)
end
[running_price, ceil].min
end
The loop steps from the current price up to overcome one ladder increment at a time. The default ladder is { 0.. => 1 } — increment of 1 (lib/auctify/configuration.rb:24). overcome is another bidder's max_price, which F-07 shows is attacker-controlled up to 9 999 999 999.
Attack: account A places max_price=9_999_999_999 (price auto-set to the minimum, so it looks like a normal bid). Account B places max_price=9_999_999_998. solve_limits_fight's second branch (:193-195) calls increase_price_to(overcome: 9_999_999_998, ceil: 9_999_999_999) → ~10^10 iterations. The Puma thread is pinned indefinitely. Repeat to exhaust the pool.
Auctea configures its own bid_steps_ladder per sale (app/models/auctify/sale/base.rb:12), so the real iteration count depends on the largest increment in that ladder — confirm by reading Auctea's Auctify.configure block and a production auctify_sales.bid_steps_ladder value. At a 10 000 CZK top step it is ~10^6 iterations: seconds of CPU per request, still a usable DoS on auction night.
Consequence: the site goes down during live bidding. For a house that runs timed online auctions, an outage in the final minutes means the auction has to be re-run, and bidders who could not bid have a complaint.
Fix: cap max_price (F-07 fixes this), and rewrite the loop arithmetically instead of iterating, or add raise if (overcome - current_price) / smallest_step > 10_000.
PROVED. Every successful bid calls extend_end_time (app/models/auctify/sale/auction.rb:324-327), which pushes currently_ends_at to bid_time + auction_prolonging_limit_in_seconds (default 2 minutes, lib/auctify/configuration.rb:20). There is no cap on the number of extensions and no absolute end time. check_same_bidder (bids_appender.rb:164-170) blocks self-overbidding, but two accounts alternating minimum increments extend the lot by 2 minutes per bid, forever, at zero cost.
Consequence: a lot never closes; consignor settlement is blocked; the auction-night schedule collapses. Also a price-manipulation vehicle — a consignor with two accounts can run the price up and simply stop.
Fix: a hard max_extensions or absolute cutoff on currently_ends_at, plus an alert when one lot exceeds N extensions.
PROVED that no shared lock exists. The exact interleaving is SUSPECTED — confirm with a load test.
Auction#bid! (app/models/auctify/sale/auction.rb:158-170) opens a plain ActiveRecord::Base.transaction with no lock!, no SELECT FOR UPDATE, and no advisory lock. BiddingCloserJob does take one: with_advisory_lock("closing_auction_#{auction_id}") (app/jobs/auctify/bidding_closer_job.rb:19). The two never contend, because the bidding path never asks for that lock.
Two consequences, both on PostgreSQL's default READ COMMITTED:
auction.ordered_applied_bids (bids_appender.rb:122-124) before either commits. check_price_minimum and check_same_bidder both evaluate against the stale winning bid, so both pass. Two bids are appended at the same price; applied_bids_count (auction.rb:178) is whatever the last writer computed. A bidder firing two parallel requests can raise the price by one increment while placing two bids, or defeat you_cannot_overbid_yourself.
check_auction_state (bids_appender.rb:172-177) reads auction.in_sale? from the in-memory object loaded at auctions_controller.rb:38, before the request did any work. If BiddingCloserJob commits close_bidding! in between, the controller's object is stale, the check passes, and succesfull_bid! writes a new current_price and current_winner onto an auction already in bidding_ended. close_bidding's after block has already copied current_winner into winner (auction.rb:70-72), so the two fields now disagree.
Consequence: a bidder who scripts a request to land in the closing millisecond can become current_winner after the hammer. Which of winner or current_winner the invoice is built from is Auctea's code, so the outcome is SUSPECTED — but two disagreeing winner fields on a closed lot worth millions is a dispute you will lose in public.
Fix: wrap the body of bid! in the same advisory lock the closer uses (Auctify::Sale::Auction.with_advisory_lock("closing_auction_#{id}")), and auction.lock! + reload inside the transaction before check_auction_state. The dependency is already there (auctify.gemspec:37) and the pattern is already used ten lines away in the job.
PROVED by absence. With no throttle anywhere, a script can:
/auctify/api/v1/auctions/1..N to map every lot including unpublished ones (F-05);POST /auctify/bidder_registrations), each one self-approved via the permitted aasm_state.Fix: rack-attack in front of POST /auctify/api/v1/auctions/*/bids (per user and per IP) and the registration path. This is a host-app change, not an auctify change.
auctify.gemspec:34-39 declares five runtime dependencies. Gemfile.lock resolves the full tree, including folio's, so both are assessed. .ruby-version / .tool-versions pin Ruby 2.6.6.
There is no CI, no brakeman, no bundler-audit, no Dependabot config — ls .github finds nothing and grep -rn "brakeman\|bundler-audit" returns nothing. The only dependency hygiene in the history is manual: 3932905 "security bump of rails to 6.0.3.7" (2021-05) and d31788f "security updates" (2022-06, Gemfile.lock only). Nothing since.
| Component | Pinned | Status | Notes |
|---|---|---|---|
| Ruby | 2.6.6 (.ruby-version) | EOL 2022-03-31. Also misses every 2.6.x security release: 2.6.7, .8, .9, .10 | CVE-2020-25613 (WEBrick request smuggling), CVE-2021-31799 (RDoc command injection), CVE-2021-31810, CVE-2021-32066 (Net::IMAP StartTLS stripping), CVE-2021-41816 / -41817 / -41819 (CGI buffer overrun, Date ReDoS, cookie parsing), CVE-2022-28739 |
| Rails | 6.1.6 (2022-05) | Rails 6.1 left security support 2024-10-01. No patch exists to move to | CVE-2022-32224 (ActiveRecord YAML deserialization → RCE, fixed 6.1.6.1 — this pin is one patch short), CVE-2022-44566, CVE-2023-22792/-22794/-22795/-22796, CVE-2023-28362, CVE-2023-38037, CVE-2024-26144, CVE-2024-54133 |
| rack | 2.2.3.1 | many | CVE-2022-44570/-44571/-44572 (ReDoS), CVE-2023-27530 (multipart DoS), CVE-2023-27539, CVE-2024-25126, CVE-2024-26141, CVE-2024-26146, CVE-2025-27610 (Rack::Static traversal) |
| rails-html-sanitizer | 1.4.2 | vulnerable | CVE-2022-32209 — XSS, fixed in 1.4.3. One patch release away |
| loofah | 2.18.0 | vulnerable | CVE-2023-23515 (XSS, fixed 2.19.1) |
| nokogiri | 1.13.6 | vulnerable | CVE-2022-40303 / -40304 (libxml2, fixed 1.13.9), CVE-2023-29469 / -28484, CVE-2024-25062 |
| dragonfly | pinned = 1.3 by folio | vulnerable | CVE-2021-33564 — argument injection → RCE, fixed in 1.4.0. File-upload/image path. Belongs to folio's review, flagged here because it is the most severe entry in auctify's lockfile |
| rexml | 3.2.5 | vulnerable | CVE-2024-35176, -39908, -41123, -43398 (parser DoS) |
| puma | 5.6.4 | vulnerable | CVE-2023-40175 (HTTP request smuggling, fixed 5.6.7). Dev/dummy dependency here; production Puma is Auctea's |
| omniauth | 1.9.1 | superseded | CVE-2015-9284 (request-phase CSRF) is only mitigated by omniauth-rails_csrf_protection, which is not in this lockfile. folio HEAD adds it (folio.gemspec:62); the 2022 pin does not |
| fast_jsonapi | 1.5 | unmaintained | Netflix archived it in 2019; 1.5 is the final release. A direct auctify dependency (auctify.gemspec:36). Migrate to jsonapi-serializer |
| mini_racer / libv8-node | 0.4.0 / 15.14.0.1 | unmaintained | V8 from Node 15, EOL 2021. Embedded JS engine with a long CVE tail |
| audited | 4.10.0 | old | 4.x line; current is 5.x/7.x |
| acts-as-taggable-on | 7.0.0 | old | SUSPECTED CVEs; confirm with bundle audit |
| devise | 4.8.1 | old | SUSPECTED; confirm with bundle audit |
| aasm | 5.1.1 (auctify.gemspec:35) | old, no known CVE | Pinned ~> 5.1.1, i.e. frozen at a 2020 patch line |
| with_advisory_lock | 4.6.0 | fine | — |
| yabeda | 0.11.0 | fine | — |
message_bus is not a dependency of auctify at all. It is folio's (folio/folio.gemspec:52), and it is absent from auctify's 2022 lockfile entirely. auctify also declares no ActionCable usage — the only app/channels files are in the throwaway dummy app. So the live-bidding transport you measured on www.acb.cz is implemented outside this repository, in folio or in Auctea. Nothing in auctify authorises, scopes or rate-limits what is broadcast on that channel. Whether bid events leak other bidders' identities to every subscriber is SUSPECTED and unreviewable from here — it needs Auctea's MessageBus.subscribe / publish calls and its MessageBus.user_id_lookup configuration. That is the single biggest blind spot in this review, and I would put it at the top of the next review's scope.
Severity is on the pin, not each CVE. The exact patch levels in Gemfile.lock are auctify's development resolution; Auctea resolves its own. Confirm with bundle audit check --update on the production Gemfile.lock — that is the only number that matters. But the rails ~> 6.1.4 constraint in auctify.gemspec:34 is binding on the host app: as long as Auctea depends on published auctify, it cannot run a supported Rails. That constraint alone is the CRITICAL part.
285 commits, 2021-02-08 → 2024-11-15. Development effectively stopped mid-2022: 3 commits in Sept 2023, 4 in Nov 2024, nothing since.
Commits matching security|fix|vulner|credential|secret|CVE|auth (case-insensitive):
| Commit | Date | What it actually was |
|---|---|---|
3932905 | 2021-05-11 | "security bump of rails to 6.0.3.7" — Gemfile.lock + gemspec only |
d31788f | 2022-06-02 | "security updates" — Gemfile.lock only, 96 insertions / 118 deletions. No code change |
a601b95, 3f20c5e, a385a1b | 2021 | Dependabot bumps (puma, nokogiri). Dependabot has not run since |
c8f50ad | 2024-11-05 | "set ceil for bid price; close #450" — the price ceiling of F-07. Shipped with the with_limit? bypass |
96449f1 | — | "storing dont_confirm_bids even for failed bid" — the design decision behind F-04's failure-branch write |
Everything else matching fix | — | Bidding-arithmetic corrections (8dae11b, ac4f48d, f0ed02d, 23ee8ec, 6f8d13c, 1d0f28e), not security |
No commit in the history removes a credential. git log --all --diff-filter=D over .env, master.key, credentials, .pem, .p12 returns nothing. Confirmed clean.
What the history says about the asset: there has never been a security-specific code change in this repository — only two lockfile bumps, the most recent 3 years and 3 months ago. The # TODO handle authorization here if needed at app/controllers/auctify/api/v1/base_controller.rb:9 has been there since the API was written and is still there at HEAD. The pattern of "fix" commits shows the maintainer's attention went entirely to bidding arithmetic — which is why that part is good and the HTTP layer is not.
This is not a bad codebase. The domain core is careful, and several things are done better than I usually see.
| What | Where | Why it matters |
|---|---|---|
| The actor is never taken from a parameter in the real bidding path | app/controllers/auctify/api/v1/auctions_controller.rb:45-54 | bid_params permits only price and max_price; the registration is looked up from current_user, and bid_params is merged first so it cannot override. Exactly right |
Bid#bidder= refuses a model that was not auctified as a buyer, and refuses to re-assign a bidder over an existing registration | app/models/auctify/bid.rb:51-55 | Defence in depth at the model layer, below the controller |
| The sold price must equal what bidding produced, and the buyer must equal the winner | app/models/auctify/sale/auction.rb:264-280 (buyer_vs_bidding_consistence) | This is the single best control in the engine. An operator cannot type in a hammer price. It is validated on every save |
| Auction terms freeze once bids exist | app/models/auctify/sale/auction.rb:347-359 (forbidden_changes) | Changing ends_at or offered_price on a lot with bids is rejected and the attribute is written back, including dependent columns. Nobody shortens a running auction |
| Auctions with bids cannot be destroyed | app/models/auctify/sale/auction.rb:361-363, plus dependent: :restrict_with_error on every bid association (auction.rb:18,22; bidder_registration.rb:12,18) | The evidence trail cannot be deleted through the ORM |
| Bids are cancelled, never deleted | app/models/auctify/bid.rb:23-27, cancelled column with an index | Soft-delete on the record that decides who pays. Correct instinct |
| Bid closing is serialised with an advisory lock | app/jobs/auctify/bidding_closer_job.rb:19-22, with a reload + in_sale? recheck inside the lock | The author understood the concurrency problem. The defect in F-10 is that the bidding path does not join the same lock — the knowledge was there |
| A user-supplied cache parameter is regex-validated before coercion | app/controllers/auctify/api/v1/auctions_controller.rb:10 (params[:updated_at].match?(/\A\d+\z/)) | Small, but the habit is right |
| No SQL injection, anywhere | §4 | Every fragment is bound. In 8 946 lines with a lot of hand-built scopes and one raw Arel join, that is a real result |
| No XSS, anywhere | §5 | Zero uses of html_safe, raw or sanitize in 21 templates and 5 helpers |
| No secrets, ever committed | §7 | Clean tree, clean history |
| CSRF is on and never skipped | §6 | Including on the JSON API, which is where most engines get lazy |
| The bidder/operator authorisation boundary is actually tested | test/controllers/auctify/api/v1/console/bids_controller_test.rb:73-99 | A signed-in bidder is asserted to receive 401 on the console endpoint. Very few engines test the negative case |
| 7 355 lines of tests against 8 946 of code | test/ | test/services/auctify/bid_appender_test.rb is a serious table-driven test of the bidding arithmetic — limit fights, autobid, overbidding yourself, ladder steps. This is where the maintainer's effort went, and it shows |
The shape of the problem is clear: the auction engine is well built; the web layer around it is scaffolding that was never finished. The two are separable, which is the good news for remediation.
3 / 10.
Points for: a genuinely sound bidding core with real invariants (buyer_vs_bidding_consistence, forbidden_changes, restrict-on-destroy, soft-cancel), no injection, no XSS, no secrets, CSRF intact, and a strong test suite for the domain arithmetic.
Points lost for: an authorisation layer that does not exist (# TODO handle authorization here if needed still at HEAD), five scaffold controllers shipped in the gem with unauthenticated write access to bids, registrations and sales, a money-critical ceiling that a single extra parameter bypasses, a bidding path that takes no lock while the closing path does, zero rate limiting, and a platform pin (Ruby 2.6.6, Rails ~> 6.1.4) that makes the host app unpatchable. Add: no CI, no brakeman, no bundler-audit, no dependency updates in 3 years, and an upstream that is effectively dormant.
For a hobby gem this is a 6. For the engine that decides who owns a painting at three million crowns, it is a 3.
config/routes.rb:5-7 (and app/controllers/auctify/bids_controller.rb:61, bidder_registrations_controller.rb:64-65, sales_controller.rb:65)
First, the 30-second check: curl -s -o /dev/null -w '%{http_code}\n' https://www.acb.cz/auctify/bids — a 200 or 422 means everything below is live right now; a 404 means you can drop this to third place.
Attack, if live:
GET /auctify/bids → the full bid table, including registration_id for every bidder on every lot.POST /auctify/bidder_registrations with bidder_registration[bidder_id]=<victim user id>&bidder_registration[bidder_type]=Folio::User&bidder_registration[auction_id]=<lot>&bidder_registration[aasm_state]=approved → an approved registration for a lot, as someone else. The permitted aasm_state (:64) is the self-approval.POST /auctify/bids with bid[registration_id]=<that id>&bid[price]=1 → a bid that never passes through BidsAppender: no minimum increment, no in_sale? check, no closing time, no self-overbid rule. Auctify::Bid validates only price <= max_price and rounding (app/models/auctify/bid.rb:13-14).DELETE /auctify/bids/<winning bid id> → the real winner's bid is gone.Fix now: delete those three resources lines from the mounted routes, or put the engine behind a constraint that admits only auctify/api/v1/auctions. Route removal is one line and needs no controller work.
app/services/auctify/bids_appender.rb:152
Attack: any approved bidder, one request, no timing needed:
POST /auctify/api/v1/auctions/<live lot id>/bids
Cookie: <their own session>
bid[price]=9999999999&bid[max_price]=9999999999&terms_confirmation=1
check_price_minimum (:141-149) passes — the price is above the minimum.check_price_maximum (:151) hits return if bid.with_limit? at line 152 and never evaluates the ceiling, because max_price is present.set_price_for_limit_bid (:46-56) returns immediately — bid.price is not blank — so the attacker's figure survives untouched.solve_winner → increase_bid_price (:198-204) keeps new_bid.price at 9 999 999 999.update_auction! (:83-92) → succesfull_bid! (app/models/auctify/sale/auction.rb:173-182) writes it to current_price and makes the attacker current_winner.The lot now shows 9 999 999 999,99 CZK on the public page. The guard that was supposed to prevent this was added on 2024-11-05 (c8f50ad) specifically to close a production incident (issue #450), and shipped with this hole. The test suite exercises the ceiling only with max_price: nil (test/services/auctify/bid_appender_test.rb:530-563), which is why nobody caught it.
Fix now: delete line 152 and check both fields (patch in F-07). One line removed, two lines added. Add a DB check constraint on auctify_bids.price and .max_price as a backstop.
app/models/auctify/sale/auction.rb:158-170 vs app/jobs/auctify/bidding_closer_job.rb:19
Attack: a bidder who knows currently_ends_at — it is in the public API response (app/serializers/auctify/sale/auction_serializer.rb:9) — scripts a POST .../bids to arrive in the same second the auction closes, and fires it repeatedly.
find_auction (app/controllers/auctify/api/v1/auctions_controller.rb:38) loads the auction into memory while it is still in_sale.BiddingCloserJob takes with_advisory_lock("closing_auction_<id>") (bidding_closer_job.rb:19) and commits close_bidding!, which copies current_winner into winner (auction.rb:70-72).bid! (auction.rb:161) opens a transaction that asks for no lock at all and does not reload.check_auction_state (bids_appender.rb:174) reads auction.in_sale? off the stale in-memory object → passes.succesfull_bid! (auction.rb:173-182) writes a new current_price and current_winner onto a lot already in bidding_ended. winner and current_winner now name different people on a sold lot.The same missing lock lets two parallel requests from one bidder defeat check_same_bidder and the minimum increment, since both read the winning bid before either commits.
Fix now: wrap the body of bid! in the same advisory lock the closer already uses, and lock! + reload the auction inside the transaction before check_auction_state runs. with_advisory_lock is already a declared dependency (auctify.gemspec:37) and the exact pattern is already written ten lines away in bidding_closer_job.rb:19. This is the cheapest of the three to get right and the hardest to detect after the fact.