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
curlagainst 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:
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.
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:
# 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:
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:
<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
- Add an
internallocation whose prefix matchesdownloads.xaccel_prefix, default/kahunacart-files/, and whose alias points at the files root:
location /kahunacart-files/ {
internal;
alias /var/lib/kahunacart/files/;
}
- Set
downloads.methodtoxaccel.
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
- Install
mod_xsendfile. - Add both directives to the server or vhost configuration:
XSendFile On
XSendFilePath /var/lib/kahunacart/files
- Set
downloads.methodtoxsendfile.
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:
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:
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.
Order links expire
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:
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:
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.
Site address for emailed links
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:
system.custom_base_url. When it is set, every link uses it.- The entry of
security.site_urlswhose host the customer was on, so a store served on several of its own addresses links each customer to the one they used. - The first entry of
security.site_urls. Anything sent from the command line uses this one too. - 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.
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}/mcpor `{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_urlat Ollama on the same host. - The API key is stored encrypted, like a payment provider's secret.
env:NAMEkeeps 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_timeoutseconds 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 statusorblueprints.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 |