Skip to content

Powered by Grav CMS + Helios Theme

Hardening a KahunaCart store

What a production store needs beyond the defaults: web-server rules for the directories no visitor may fetch, delivery methods that hand a file transfer to the web server, correct visitor counting behind a CDN, order link expiry, and where to report a vulnerability. The defaults are safe on Apache and on a store with nothing in front of it.

Before you begin

  • Have access to the web server configuration, or to whoever does.
  • Be able to run curl against the store from outside the server.

Deny the data directories

KahunaCart keeps four things under user/ that no visitor may fetch directly.

Path What is in it
user/data/kahunacart/db/kahunacart.sqlite Every order, customer, address and email address on the store
user/data/kahunacart/files/ The files people paid for, before any grant is checked
user/data/kahunacart/exports/ Store bundles: the whole store in one zip, customers and orders included. See Export and restore
user/data/kahunacart/ All of the above, plus anything a future add-on puts beside them

On first use, KahunaCart writes a deny-all .htaccess and an empty index.html into the database directory, the files root and the bundles folder, and chmods the SQLite file to 0640. That covers Apache only. nginx and Caddy ignore .htaccess and serve the files until you add rules.

Test it from outside the server, before you take orders:

BASH
curl -sI https://your-site.com/user/data/kahunacart/db/kahunacart.sqlite | head -1
curl -sI https://your-site.com/user/data/kahunacart/files/ | head -1

Anything other than 403 or 404 is a problem.

Warning

A .sqlite file served over HTTP is your entire customer list, every address and every order total, in one download. Check this on every environment, including staging copies of production data.

Move the data directories out of the document root

This is the fix to prefer.

YAML
database:
  sqlite:
    path: '/var/lib/kahunacart/kahunacart.sqlite'
downloads:
  path: '/var/lib/kahunacart/files'

PHP still needs read and write access to both. The xaccel and xsendfile delivery methods still work.

Deny them on nginx

Add this to the server block, above the PHP location:

NGINX
# The whole KahunaCart data tree: database, WAL files, downloadable files.
location ^~ /user/data/kahunacart/ {
    deny all;
    return 404;
}

# Belt and braces for the database itself, wherever it ends up.
location ~* \.(sqlite|sqlite3|db|db-wal|db-shm)$ {
    deny all;
    return 404;
}

^~ stops a later location ~ \.php$ claiming a URL under that prefix. return 404 rather than 403 so the path does not confirm it exists.

Note

These rules do not conflict with the xaccel delivery method, because an internal location is not reachable from outside. See Serve downloads through the web server.

Deny them on Caddy

In the site block:

NGINX
example.com {
    root * /var/www/example.com

    @kahunacart_data path /user/data/kahunacart/*
    respond @kahunacart_data 404

    @databases path *.sqlite *.sqlite3 *.db *.db-wal *.db-shm
    respond @databases 404

    php_fastcgi unix//run/php/php8.3-fpm.sock
    file_server
}

Matchers are evaluated in order, so both respond directives must come before file_server.

Deny them on Apache

There is nothing to do, as long as AllowOverride lets Apache read the shipped .htaccess files. If your vhost sets AllowOverride None, put the equivalent in the vhost:

APACHE
<DirectoryMatch "^/var/www/example\.com/user/data/kahunacart/">
    Require all denied
</DirectoryMatch>

Serve downloads through the web server

downloads.method decides who pushes the bytes.

Method Requires Holds a PHP worker Use it when
php (default) Nothing Yes, for the whole transfer Files are small, or you cannot configure the web server
xaccel nginx, one internal location No You are on nginx and sell anything large
xsendfile Apache with mod_xsendfile, or LiteSpeed No You are on Apache and sell anything large

The php method streams in fixed chunks and honours a Range request, so memory is not the cost. The cost is that one 400 MB download holds one PHP process for as long as the customer's connection lasts, and a handful of slow downloads on a small worker pool can starve the site.

xaccel and xsendfile hand the file to the web server and exit immediately, with no memory or worker cost and the server's own range handling.

Configure X-Accel-Redirect on nginx

  1. Add an internal location whose prefix matches downloads.xaccel_prefix, default /kahunacart-files/, and whose alias points at the files root:
NGINX
location /kahunacart-files/ {
    internal;
    alias /var/lib/kahunacart/files/;
}
  1. Set downloads.method to xaccel.

Keep the trailing slashes on both the location and the alias.

Important

The internal directive is not optional. Without it, that location is a public file listing of everything you sell.

Configure X-Sendfile on Apache

  1. Install mod_xsendfile.
  2. Add both directives to the server or vhost configuration:
APACHE
XSendFile On
XSendFilePath /var/lib/kahunacart/files
  1. Set downloads.method to xsendfile.

XSendFilePath is mandatory. Point it at the files root and nothing wider. It is a server- or vhost-level directive and cannot go in .htaccess. LiteSpeed implements the same header natively and usually needs no configuration.

Full snippets, including what a misconfigured method looks like from the customer's side, are in Digital products → Delivery methods.

Restrict where downloadable files may live

downloads.allowed_roots is the list of directories a purchased file may be served from. It defaults to [downloads.path].

A file is refused at upload if the bytes did not land inside an allowed root, and again at delivery if the path no longer resolves inside one. Both comparisons go through realpath(), so a symlink, a .. or a bind mount is followed before anything is compared.

Add a second root when files live in two places, usually during a migration:

YAML
downloads:
  path: '/var/lib/kahunacart/files'
  allowed_roots:
    - '/var/lib/kahunacart/files'
    - '/srv/legacy-downloads'

Absolute paths and Grav stream URIs both work. A configured root that does not exist is dropped, not created.

Audit what a store holds:

BASH
bin/plugin kahunacart downloads:check

It lists every product file that cannot be delivered, says whether the bytes are missing or only outside the roots, and exits non-zero when it found something. See CLI → downloads:check.

The receipt, the invoice, the invoice PDF and the pay page are each reached by a signed link that stops working.

Link Config key Default
Receipt security.order_link_days 30 days
Invoice and invoice PDF security.invoice_link_days 30 days
Pay page orders.pay_link_days 7 days

Signatures come from a per-site secret that KahunaCart creates once, from 32 random bytes, and keeps in its own database. To rotate it, delete the security.secret row from kahunacart_settings; that invalidates every link the store has already emailed.

Three things open an order page: a valid unexpired link, a logged-in customer whose id is on the order, or an admin holding kahunacart.orders.view. Anything else gets an expired-link page with a button that emails a fresh one to the address on the order. That page shows nothing about the order, whether or not the hash is real.

Important

A store upgrading from a build before signed links has emailed links that carry no signature. Those now land on the expired page. The button on it is the customer's way through and needs no help from you.

A pay link also carries a deadline on the order itself, written when the link is issued. When a customer says their pay link no longer works, open the order in the admin and press Reissue, or Reissue & email to send it straight to them. See Orders and the admin → Pay links.

Rate limits

Every route a visitor can hammer is counted per client address in a named bucket.

Bucket Covers Default
cart_write Add, update, remove, apply a coupon 60 per minute
checkout The checkout submit 10 per minute
order_lookup Receipt and pay pages, by hash 30 per minute
invoice The invoice and its PDF 20 per minute
download Download links 30 per minute
link_request "Email me a new link" 3 per hour
fragment Estimate, search and filter fragments 120 per minute
account_offer The post-checkout account offer 3 per hour
agent_read Product feeds, MCP initialize, tools/list and the catalog, policy and get_ tools, UCP GETs and catalog calls 120 per minute
agent_write Agent carts and checkouts: create, update, cancel 60 per minute
agent_complete Completing an agent checkout 10 per minute
agent_profile_fetch The store's own fetches of platform UCP profiles, counted store-wide 30 per minute

Override any of them under security.rate_limits:

YAML
security:
  rate_limits:
    checkout: { limit: 5, window: 60 }
    download: { limit: 0, window: 60 }   # 0 turns the bucket off

A limited request answers 429 with a Retry-After header, and is logged at info level.

The three buckets that answer an order hash (order_lookup, invoice and link_request) are counted twice: once per address and once per order, with the same limit on each. That stops one host working through hashes, and a botnet working on a single hash from many addresses.

Counts live in kahunacart_rate_limits in the store's own database, so every PHP worker and every node counts against the same rows. Old rows are swept automatically and need no cron.

Trusted proxies

Behind Cloudflare, a load balancer, or an ingress controller, REMOTE_ADDR is the proxy and every visitor shares one rate-limit bucket. Name the proxies:

YAML
security:
  trusted_proxies:
    - '173.245.48.0/20'
    - '103.21.244.0/22'
    - '2400:cb00::/32'

X-Forwarded-For is read only when the connection itself came from an address on that list. With the list empty, the default, the header is ignored.

Warning

Do not list a range you do not control. Trusting X-Forwarded-For from an address anybody can connect from turns every rate limit into a suggestion.

IPs and CIDR ranges in either family are accepted. The chain is walked from the right past every trusted hop, and the first untrusted one is the client. A chain that is trusted the whole way down falls back to its leftmost entry.

Tip

Cloudflare publishes its ranges at cloudflare.com/ips. Put both the IPv4 and IPv6 lists in trusted_proxies. A load balancer inside your own network is usually one entry, such as 10.0.0.0/8.

To check it, hammer one endpoint through the proxy from two different addresses. They should get two budgets.

Every link in an email (a sign-in link, a set-password link, a cart link, a receipt) has to start with the store's address. A request's address comes from its Host header, and on a server that answers any host, the visitor gets to pick it. So KahunaCart never builds an emailed link from a request's host unless the store has said that host is its own. It looks, in this order, for:

  1. system.custom_base_url. When it is set, every link uses it.
  2. The entry of security.site_urls whose host the customer was on, so a store served on several of its own addresses links each customer to the one they used.
  3. The first entry of security.site_urls. Anything sent from the command line uses this one too.
  4. With neither setting filled in, the address of a signed-in administrator's own request, so an invitation sent from the admin still works on a new store.
YAML
security:
  site_urls:
    - 'https://shop.example.com'
    - 'https://www.shop.example.com'

The admin field is Site Addresses under Settings → Security. An entry with no scheme is read as https://.

Most stores never type it. While both settings are empty, the address a signed-in administrator is using is recorded into security.site_urls the first time they open the Admin Next dashboard or KahunaCart's admin page, or save KahunaCart's settings. The account has to be signed in to the admin with admin.super or api.access. An API key with scopes never counts, and neither does anything run from the command line. The address goes into the same file the admin's settings save would write, one notice line in the Grav log says what was recorded and by whom, and it is recorded only once, never over a value that is already there.

Only an HTTPS address is recorded. A development address (localhost, an IP, or a name ending in .test, .local and the like) never is, because settings saved on a laptop often get committed and deployed. A public store answering on plain HTTP is usually one whose HTTPS is not set up yet. In either case, add the address customers use by hand.

With no trusted address, sign-in, set-password and cart links are not sent. The visitor sees the same "check your email" page as always, so the form gives nothing away, and the reason goes to the Grav log and the job's failure record. Other emails still go out, with links that carry no address. A job held back like this is retried after a minute and then two, and after that only once the store has an address: the worker releases every one of them at the start of its next run after it sees an address, so they go out then instead of never. A worker started with --daemon sees the new address when it next restarts. Until the store names its address, the dashboard shows an alert and bin/plugin kahunacart check fails its site address check.

bin/plugin kahunacart check runs the database, download-roots, trusted-proxies and site-address checks from this page with every other pre-launch check. See Going live.

Agent endpoints

Agentic commerce adds public URLs that machines call rather than people: /.well-known/ucp at the site root, and {base}/feeds/*, {base}/mcp and {base}/ucp/* under the storefront. Each one answers 404 until its switch is on.

Let them through your CDN

KahunaCart blocks no user agent, so Google's agents, Visa Intelligent Commerce, Mastercard Agent Pay and Cloudflare's signed agents (RFC 9421 Signature-Agent) reach the store as ordinary clients. Your CDN may not be so relaxed.

  • Keep these four paths out of "block AI bots" rules, bot-fight modes and JavaScript challenges. An agent cannot solve a challenge page, and a feed fetch that gets one fails in Merchant Center's diagnostics with no hint why.
  • Allow verified bots and signed agents at the CDN if it offers the setting.
  • Set security.trusted_proxies. Agent traffic arrives from a few shared addresses, and behind a proxy with the list empty every platform shares one bucket with every shopper. A UCP request that has authenticated (a valid API key or a verified signature) is counted per platform and session instead of per address.
  • *Do not cache {base}/mcp or `{base}/ucp/.** The feeds and/.well-known/ucpsend their own publicCache-Control` and may be cached as sent.

What protects each endpoint

Endpoint Risk What stops it
GET /.well-known/ucp Disclosure, cache poisoning Built from configuration only, reading nothing from the request. It holds only public facts: endpoints, the Google Pay merchant id, the provider's publishable key and public signing keys. No cookie, no session, and 404 until every readiness problem is fixed.
GET {base}/feeds/{file} Hidden products, path traversal, bandwidth Only what a guest could buy is listed. The file name comes from a fixed list of three and never reaches a path. Optional feeds.access_key, compared in constant time. ETag and 304, files built by the worker and streamed, and the agent_read bucket.
POST {base}/mcp DNS rebinding, oversized JSON, cart enumeration, card data, internal fields The Origin check; 256 KB, 32 levels and no batches; cart and checkout ids of 128 random bits bound to the platform that made them; the card-number check on every argument; answers built from an allowlist; arguments checked against each tool's schema; errors never echo input; no session read or written; the agent buckets.
{base}/ucp/* SSRF through platform profiles, replay and double charges, price tampering, token leakage, cross-platform access Profiles fetched over HTTPS only, with no redirects, private and metadata ranges refused, short timeouts, a 64 KB cap, a cache and the store-wide agent_profile_fetch budget. Idempotency-Key on every write, failing closed. Prices, tax, shipping and totals come only from the store's calculator. The Google Pay token is never stored, logged or echoed. Every session is bound to the platform that created it. X-API-Key for Google when set, and RFC 9421 signatures verified when present.
GET {base}/ucp/orders/{id} Order data Authenticated platforms only, only orders from their own sessions, and the order hash rather than the numeric id. A missing order and another platform's order answer alike.
GET {base}/ucp/continue/{id} Cart takeover A signed link for that session only, expiring with it. No database write, and the cart_restore bucket.
Outbound order webhooks SSRF, data to the wrong party The same guarded fetcher. Sent only for orders placed through that platform's session, signed with the store's key.
The admin routes Privilege kahunacart.settings for the status, key rotation and feeds, kahunacart.orders.view for sessions, kahunacart.orders.manage for Mark delivered. demo.readonly refuses the writes.

Semantic search sends text to a provider

With semantic search on, every product's title, summary, description, category names, tags, brand and attribute and option values go to the embedding provider, and so does every search term a shopper types. Customer data never does. Nothing from an order, a cart or an account is embedded.

  • To keep it all on your server, point base_url at Ollama on the same host.
  • The API key is stored encrypted, like a payment provider's secret. env:NAME keeps it out of the config files altogether.
  • A shopper never waits on a batch. Embedding runs only on the job worker, and a search waits at most query_timeout seconds for the provider before answering from keywords.
  • The vectors live in the product index under user/data/kahunacart/search/, which the deny rules above already cover.

Session-built pages are never shared-cacheable

The cart, the checkout, every receipt, the invoice, the pay page, the expired-link page and the whole account area answer with Cache-Control: private, no-store, max-age=0. Catalog pages stay cacheable until the visitor has a cart or an account, at which point they become private, no-cache. While the store is coming soon or paused, every page that would otherwise be cached is private, no-cache, and the pages above keep private, no-store; purge your CDN after you change the status. See Configuration → Store status.

Grav's default is system.pages.expires: 604800, which would otherwise put a week-long max-age on a page built from somebody's session, and a CDN would hand it to the next visitor.

Warning

If you put a CDN in front of a KahunaCart store, do not override those headers, and do not configure the CDN to ignore Cache-Control on the storefront routes.

Reporting a vulnerability

Do not open a public issue for a security problem; a report on the public tracker is a disclosure before there is a fix. Report privately to [email protected] with:

  • The KahunaCart version, from bin/plugin kahunacart status or blueprints.yaml.
  • The Grav version and the web server.
  • What an attacker gets, and the smallest set of steps that shows it.
  • Whether the store needs to be configured unusually for it to work.

You get an acknowledgement, then an assessment once the report has been reproduced. Give a fix a reasonable window before disclosing publicly. The advisory credits you unless you ask it not to.

What counts
In scope Reading or changing data belonging to another customer or to the store, completing an order without paying, downloading a file you did not buy, escalating an admin permission, running code
Out of scope Findings that require an already-compromised admin account, missing headers with no exploit attached, automated scanner output with nothing behind it, anything in Grav core or another plugin