CORS, rate limiting and audit#
Protections of the Api layer that are unrelated to each other and each solves
something different. Plus what is public even though it should not be. The switches:
config/Shared/security.neon.
| Protection | What it protects against | Where the config is |
|---|---|---|
| CORS | a foreign site calling the API from a signed-in user's browser | config/Shared/cors.neon |
| Rate limiting | password guessing, mail bombing and bulk draining of an endpoint | config/Shared/ratelimit.neon |
| Application key limits | an application (or one of its callers) draining the whole Api | api.clientKey.limits in config/Shared/api.neon, the client's columns |
| Audit log | having nothing to reconstruct an incident from | the cms_system_auth_log table |
The master switches#
security:
cors: true # CORS headers + preflight 204
rateLimit: true # brute-force protection on /auth/login and /auth/refresh
auditLog: true # writing into cms_system_auth_log
reCaptcha: true # reCAPTCHA in the front forms
reCaptcha: false gives the forms an empty field instead of reCAPTCHA
(ReCaptchaToggleExtension) and the templates do not load the Google script — required on
localhost, which Google does not support and where submitting would be silently blocked. Details
in External connections → reCAPTCHA.
Each switch disables only its own protection; the detailed configuration stays in
its own file. The JWT guard is not here — it is driven by jwt.guardMode (log = logs
only, enforce = 401), currently enforce.
Not every limit obeys the master switch — and that is deliberate
showPhone and sendNewPassword do not follow security.rateLimit. They are
the last brakes before a write into someone else's account, and turning them off
with a single switch together with login brute-force protection would mean turning
off two very differently serious things at once. Anyone wondering "why am I being
throttled when rate limiting is off" is looking for this.
The same goes for the application key limits —
security.rateLimit does not turn them off.
CORS#
CORS is the server's answer to the browser's question "may this foreign site read what
you return?". It is applied by CorsTrait::applyCors() in the Api presenter.
| Step | Behaviour |
|---|---|
a request with no Origin header and not OPTIONS |
same-origin → CORS is not involved at all |
| an allowed origin | Access-Control-Allow-Origin + Vary: Origin (+ Allow-Credentials) |
preflight (OPTIONS) |
returns 204 and ends the request, whether the origin is allowed or not |
| a disallowed origin | the headers are not sent, the browser discards the response itself |
applyCors() must run BEFORE the key check and the JWT check
A preflight OPTIONS sends neither X-API-Key nor Authorization. If the key check or
the JWT guard saw it first, they would return 401 — and the browser would never send the
actual request. It surfaces
as "it does not work from JavaScript but works from curl".
A wildcard in allowedOrigins is useless in production, not just unsafe
With allowCredentials: true the browser ignores
Access-Control-Allow-Origin: *. The code handles it (the wildcard is only used
without credentials), but the consequence is that a wildcard in the dev config looks
like a working setting that stops applying in production with authentication. List
concrete origins.
In the shared cors.neon the list of origins is EMPTY
Localhost origins do not belong in the shared configuration, otherwise production would get
them too. Each machine adds its own origins in config/Shared/local.neon (development:
localhost ports; production: partners and the mobile web); the lists are added together
when the configuration is merged. A forgotten local.neon on a new machine shows up as
"from the browser it does not work, from curl it does".
CORS protects the browser, not the server
A request from curl, from a server or from a mobile app is not restricted by CORS at
all. It is not authorisation and must not be mistaken for it — that is the JWT guard
and RBAC. X-API-Key is in allowedHeaders so that the browser allows it in
the preflight.
Rate limiting#
Hits are counted per key in a sliding window (the application key limits use fixed windows,
see below). The configured limits
(config/Shared/ratelimit.neon):
| What | Key | Limit | Window |
|---|---|---|---|
/auth/login |
(user, IP), the name hashed |
5 | 5 min |
/auth/login — account across IPs |
account id | 20 | 1 hour |
/auth/login — failures of one known device |
device id + account | 10 | 1 hour |
/auth/refresh |
IP | 30 | 5 min |
| revealing a phone number | IP | 20 | 1 hour |
| forgotten password — IP | IP | 10 | 1 hour |
| forgotten password — account | a hash of the username | 3 | 1 hour |
| resend activation — IP / account | IP / a hash of the name | 10 / 3 | 1 hour |
| advert editing password (failures) — advert / IP | advert id / IP | 5 / 20 | 15 min / 1 hour |
| "Recommend" — IP / recipient | IP / normalised e-mail | 10 / 3 | 1 hour |
content created by a signed-in user (contentCreate) — user / IP |
kind of content + user / IP | 30 / 60 | 1 hour |
content created anonymously (anonymousInsert) — e-mail / IP |
kind of content + e-mail / IP | 5 / 20 | 1 hour |
response to an advert (advertResponse) — IP / IP + advert |
IP / IP and advert id | 10 / 3 | 1 hour |
reply in a message thread (messageReply) |
hash of the user id | 30 | 10 min |
confirming an advert with an e-mailed key (advertConfirm, failures) — record / IP |
record id / IP | 10 / 20 | 15 min / 1 hour |
front complaint (complaintSubmit) — user / IP |
user id / IP | 3 / 10 | 1 hour |
VAT id check in VIES (vatIdCheck) — button / automatic |
user (IP without identity) / IP | 5 / 20 | 24 h / 1 hour |
municipality and category suggester (regionSuggest, categorySuggest) |
IP | 60 | 1 min |
Reservations and work item comments of the Company module are in Company.
Why an advert response is keyed by IP + advert, not by advert alone
The per-IP limit stops one sender from spreading responses over hundreds of adverts, the per-IP-and-advert limit stops flooding one advert. A key by advert alone would hand an attacker a kill switch: they would exhaust the quota and nobody else could respond.
VIES counts only real online calls
An answer from the records (a valid VAT id is taken from them for 30 days) does not draw on the
quota. The automatic check on submitting an order or payment has its own per-IP quota and does
not draw on the button quota — a refusal would charge an honest company extra VAT. A
privileged role has no limit; a limit <= 0 disables the quota.
Most limits have two dimensions, and both must pass: a per-IP limit does not stop a
botnet aimed at one account, a per-account limit does not stop one IP firing at thousands of
names. A password change by a signed-in user (update-password) draws failures from the
login values on the account key.
Application key limits#
Every external call with an application key passes the client's own limits
(ApiClientRateLimiter, called by ApiClientKeyTrait). Unlike the table above these are
fixed windows (INCR + EXPIRE in Redis, all of a client's windows in one pipeline); at a
window boundary at most twice the limit gets through.
Window (meta.window) |
Identity | Default | Where |
|---|---|---|---|
client_second |
the whole application | 20 / 1 s | checkClientKey() |
client_minute |
the whole application | 600 / 1 min | checkClientKey() |
user_minute |
userId from the JWT |
120 / 1 min | checkClientCallerLimit() |
ip_minute |
SHA-256 of the address (no userId, also on #[PublicAction]) |
120 / 1 min | checkClientCallerLimit() |
The defaults live in api.clientKey.limits; a client overrides them with the columns
limit_per_second, limit_per_minute, user_limit_per_minute, ip_limit_per_minute
(NULL = default). A window with a limit ≤ 0 is not counted. Without Redis the windows go
through DbRateLimit — two queries per window, so six extra on every request with a key.
Exceeding a limit = HTTP 429, Retry-After (seconds until the window ends) and
{"success":false,"message":"Too many requests.","data":{"retryAfter":N},"code":404}.
Only the first refusal in a window goes to the audit (rate_limited,
meta.reason = api_client_limit, meta.window, meta.limit) — a flood must not flood the
log too. A Redis outage is fail-open.
Two different 429 bodies
The key limit returns the message Too many requests., the sign-in and token refresh
limit (AuthPresenter::rateLimitedResult()) Too many requests, retry in <N>s. Both carry
data.retryAfter, the code RATE_LIMIT_EXCEEDED (404) and a Retry-After header.
Sign-in: known and unknown device#
The per-account limit across IPs would otherwise let anyone who knows a name lock the
owner out of password sign-in. Above the limit, AuthPresenter::internalActionLogin() therefore
tells apart where the attempt comes from:
| Source of the attempt | Above the limit |
|---|---|
known device: a valid signed cms_login_device cookie of the same account |
the password is checked; it passes all three gates (name+IP, account+IP, account across IPs) |
known address: a successful sign-in of the account from this IP within the last knownAddressWindow |
the password is checked, but only past the account-across-IPs gate; the pairs (name or account, IP) still throttle |
| everyone else | the same refusal as before (429, Retry-After), without checking the password |
The cookie is issued by LoginDeviceCookie (Front and Admin after a successful sign-in by
password or through a third-party service) and the Api verifies it by its signature; it is
stateless (v1.<deviceId>.<userId>.<expires>.<hmac>), and the HMAC key is derived from
%jwt.secret% with a purpose label. A device has its own failure counter
(ratelimit.loginDevice): above it the device is treated as unknown, so that a stolen
cookie does not give unlimited guessing. A successful sign-in does not reset the counters.
The reason for letting an attempt through or refusing it goes only to the auth log
(meta.reason, meta.throttle_bypass); the reply to the client is the same.
Setting ratelimit.loginDevice.* |
Default | Meaning |
|---|---|---|
limit / window |
10 / 3600 s | failures of one device, after which it counts as unknown |
cookieLifetime |
15552000 s (180 days) | cookie lifetime, every sign-in renews it |
knownAddressWindow |
2592000 s (30 days) | how long a "known address" stays valid |
An external auth/login is always an unknown device
actionLogin() deliberately does not pass the cookie on. Only a known address can
help an external client. Sign-ins from Front and Admin pass the cookie in the internal
call internalActionLogin($username, $password, $deviceToken).
The username goes into the key hashed
For two reasons: it is personal data (often an email) and has no business sitting
in a rate-limit table, and the hash also unifies Franta and franta into one key
— otherwise the limit could be dodged by changing letter case.
Rate limiting is fail-open
A Redis or database outage means allowed = true. That is intentional — signing in
must not break because of a counter — but it means an outage of the store releases
the brake, and nothing surfaces as an error.
For login, only FAILED attempts are counted
If successes counted too, a user signing in five times in five minutes would close their own window. The consequence: the limit guards against password guessing, not against load.
A per-IP limit hits a whole company at once
Users behind one public address share the quota. For integrations, key by identity rather than by address.
Procedure: adding a rate limit to a new action#
- Decide the dimension. What is the thing you are protecting — an account (key by user), a source (by IP), or both (then both must pass).
- Add the limit and the window to
config/Shared/ratelimit.neonand pass them through theRateLimitConfigconstructor (theservices:block at the end of the file). Write a comment next to each value saying why that number — nobody will raise or lower an unexplained number later. - Call
hit()in the presenter (counts and decides) orcheck()(only asks). Hash any personal data in the key. - Decide whether the limit obeys the master switch. A brake in front of a write into someone else's account should not.
- The throttled response must not reveal more than the passing one.
- Log the fact of throttling into the
authlog — that is where it belongs, because it must not be in the response.
A different response when throttled is an oracle for account existence
For "forgotten password" the code returns a constant success even when throttled
— precisely so an attacker cannot tell which attempt they are on, and above all so
that a per-account limit does not distinguish an existing account from a
non-existent one. The SendNewPasswordRateLimitTest test guards that the two shapes
match. Adding "the limit was exceeded" to the response removes this protection
without anything breaking.
Content creation quota#
Responses, posts, comments, reports and contact messages have reCAPTCHA and their own limits on
the front — but the same INSERT also goes through an external :Api:*:save with a JWT, which
every registered user has. The brake therefore sits in the Api manager, which every insert
passes: App\Core\Security\RateLimit\ContentCreationLimiter.
| Method | Applies to | Configuration |
|---|---|---|
isAllowed($scope, $userId) |
a signed-in user (an anonymous one passes) | ratelimit.contentCreate |
isAnonymousInsertAllowed($scope, $email) |
an anonymous visitor — today the contact form | ratelimit.anonymousInsert |
isActivationMailAllowed($scope, $email) |
anyone — protects recipients of activation e-mails (newsletter) | ratelimit.sendActivationEmail |
The key is always per kind of content ($scope, e.g. bazaar-response,
discussion-message), so quotas do not add up. The IP comes from IRequest, never from the
payload; in CLI it is null and only the user is counted. A limit <= 0 = no limit.
When the brake does not apply
FieldAudienceContext, filled only by the guard, decides: a privileged caller (an
administrator) is exempt, and so is an unresolved context (the emergency mode
apiRbac.internalMode: off, cron, CLI). In production both modes are enforce, so on the
Api path the brake applies to every non-privileged caller.
Procedure: wire the quota into a manager#
- The manager implements
ContentCreationLimitedInterfaceand usesApp\Core\Traits\Api\Models\Managers\ContentCreationLimitTrait. A DI decorator (Shared/decorators.neon) injects the limiter; without it (a unit test building the manager by hand) the gate limits nothing. - In
beforeSave()callguardContentCreation($isInsert, '<scope>', $userId)(on an anonymous path alsoguardAnonymousInsert(…, $email)) and return itsResultif it is notnull. Only inserts count. - The response is
RATE_LIMIT_EXCEEDED(code404) with the messageToo many new records, try again later., with HTTP 200 on the external path. - Patterns:
ResponseManager(Bazaar, Comcat),MessageManagerandMessageReportManager(Discussion),CommentManager(Base),ContactMessageManager(Blog),AdvertReportGuardTrait.
The audit log#
The cms_system_auth_log table, written by AuthLogManager::log(). The event types:
| Group | Events |
|---|---|
| sign-in | login_success, login_fail, login_external_success, logout |
| tokens | refresh_success, refresh_fail, refresh_reuse_detected, jwt_failure |
| access | api_key_failure, rate_limited; scope_denied only in older rows (key scopes were removed on 6 Oct 2026) |
meta carries free-form JSON context. The api_client_id column holds the client whose key
the request came with — AuthLogManager fills it from ApiClientContext even where the caller
does not pass it (sign-in, token refresh).
| Event | When | meta |
|---|---|---|
api_key_failure |
an unknown, disabled or revoked key (not a missing one — that goes only to auth.log as API_CLIENT_KEY reason=missing) |
reason, presenter, action |
rate_limited |
the first refusal in a key-limit window | reason = api_client_limit, window, limit |
rate_limited |
the sign-in or token refresh limit | type (login, refresh), retry_after |
Retention is handled by the
/cron/system/auth-log/purge-old cron — 90 days by default, overridable with ?days=N.
A raw token or password must NEVER go into meta
Only a hash or the first few characters. An audit log is the last place you want a usable credential to land — it survives there for months and is read by more people than the production database.
Writing to the log is fail-open
A database error is logged through Tracy and execution continues — the audit must not block a sign-in. That does mean, however, that a missing audit row does not prove the event did not happen.
Without the cron the table grows forever
Every failed sign-in attempt is a row. On a site that bots aim at, that is thousands of rows a day — and nobody notices until the administration starts being slow.
An audit log is never rewritten
Not even while cleaning up test data. Deleting by age through the cron, yes; editing the content by hand, no — a rewritten log is a forgery and not worth keeping.
Procedure: investigating suspicious activity#
- Start at
cms_system_auth_logfiltered by IP oruser_id. - Compare
login_failagainstlogin_successin the same window — password guessing looks like a long series of failures from one address. - Take
refresh_reuse_detectedseriously. A reused refresh token means somebody else has it. rate_limitedsays the brake worked — but not whether it was strict enough. For a key limit,meta.windowtells you whether one caller is draining the application (user_minute,ip_minute) or the whole traffic is (client_*).- A series of
api_key_failurefrom one address is key guessing;meta.reason = disabledorrevokedwith a specificapi_client_idis an application still calling after it was switched off. - The Tracy log (
auth) is inlog/too — it holds what was not allowed into the response: throttled password resets, audit write failures. - Touch the limit configuration last. First you have to know what happened.
CSRF on signals#
Every handle*() gets a same-origin guard automatically
Nette (AccessPolicy::applyInternalRules(), version 3.3) adds
Requires(sameOrigin: true) to every signal method unless it is marked
#[CrossOrigin] or Requires(sameOrigin: false). A foreign origin ends as a
detected CSRF.
Same-origin is recognised from the Sec-Fetch-Site header, which a non-browser client does not send
Without it Nette falls back to a check via a strict cookie. A curl client or a server-to-server call therefore will not get through a signal — which is correct as CSRF protection, but it is also why a signal cannot be used as an API endpoint.
Turning the guard off with #[CrossOrigin] means writing the authorisation yourself
A signal without the same-origin check can be triggered from any foreign page in a signed-in user's browser. That holds for actions that look harmless too — "just a like" or "just a subscription" is still a write in someone else's name.
What is public even though it should not be#
The rewrite rules live in .htaccess at two levels: the root file rewrites
everything to /www/, except the whitelisted docs/ folder. And www/.htaccess
hands existing files straight to Apache as static content.
| Path | Status |
|---|---|
docs/ |
served statically, outside the application and outside its authorisation |
docs/sql/ |
denied by its own .htaccess (Require all denied) |
www/private/ |
denied by its own .htaccess — invoice PDFs and QR codes |
www/uploads/, www/modules/** |
public, downloadable without signing in |
An existing file is served by Apache with NO check whatsoever
RewriteCond %{REQUEST_FILENAME} !-f only holds for non-existent files — on an
existing .pdf the rewrite block is not applied at all. That is exactly how the
invoices in www/private/pdf/… were downloadable by anyone who guessed the path:
the application never learned about the request, so there was nowhere to log it.
Fixed by the www/private/.htaccess file; downloads go exclusively through a PHP
proxy that sends the file with readfile() only after verifying ownership.
docs/ is public — do not generate anything with data into it
It held a complete SQL workbook with the database schema and returned HTTP 200.
Outputs containing data belong in var/.
.htaccess is a defensive layer, not a solution
It works only where Apache allows AllowOverride for that folder. The reliable fix
is not to serve the sensitive path from the server's vhost at all.
Writes on GET#
Some pages write when displayed: an opened reply detail sets read, the notification
overview shown, the detail of a contact-form message read. A write on GET can be triggered
by a foreign page (an image, a redirect, prefetching), so:
| Rule | Implementation |
|---|---|
| a write on render only from the site itself | $this->getHttpRequest()->isSameSite() — holds when the strict cookie _nss is present, which the browser does not attach to a foreign request |
| a failed write is logged, not shown to the user | Debugger::log(…) |
| the content is displayed even without the write | a planted link shows the page but flips nothing |
Places: Front ResponseDetailTrait (Bazaar, Comcat), Admin NotificationPresenter,
ContactMessagePresenter, WarehousePresenter::actionMoveUp() and actionMoveDown().
Consequence: a link from webmail does not mark the reply
A request from a foreign page does not pass isSameSite(). To mark from an email there is
therefore a link with a key.
A new page that writes on GET must use the same check
The right way is to make the write a signal with #[Requires(ajax: true)] or a POST. If it
must stay on render, wrap it in isSameSite() and log the failure. A bare GET writing
without a check is triggered by robots.
Robots and action links#
Writing signals (?do=… — favourites, cart, rating, reporting a post) must not be triggered by
robots — with a bare GET they would otherwise fill the database with favourites, carts, ratings
and reports. The protection has three layers:
| Layer | What it does |
|---|---|
#[Requires(ajax: true)] on writing handlers |
a bare GET (a robot, prefetching) ends in 403 |
CrawlerDetector in Front\BasePresenter::startup() |
a signal from a client that identifies as a robot ends in 403 before anything is loaded |
robots.txt |
Disallow for ?do=, &do=, ?_fid=, /admin/, /api/, /cron/ |
In detail, with sitemaps and procedures, in the chapter Robots, robots.txt and sitemaps.
Response headers and addresses with /www/#
Two rules in www/.htaccess apply to every Apache response.
| Header | Value | Why |
|---|---|---|
X-Content-Type-Options |
nosniff |
the browser must not guess the content type; above all for files users upload (www/uploads/) |
Referrer-Policy |
strict-origin-when-cross-origin |
only the site's address, not the whole path, goes to a foreign site |
They are set with Header always set inside <IfModule mod_headers.c>, so they work only with
the mod_headers module. HSTS is deliberately not in the file: a browser remembers it for the
whole host, so it is set in the server's virtual host.
Header always set OVERRIDES a header the application set
A page that sets its own Referrer-Policy does not keep it: Apache sets the value from
.htaccess last and replaces it. For example, the page that marks a reply as
read from an email sends Referrer-Policy: no-referrer (see
Link with a key), but the response carries
strict-origin-when-cross-origin. In that case the browser sends a foreign site only the
site's address, not the path with the key, so the key does not leak; but the intent of "no
referrer" does not apply. For the page's value to win, Header always setifempty would be
needed.
The second rule deals with duplicate addresses: the site runs through the root .htaccess
(^(.*)$ → /www/$1), so the same page would also answer under /www/…. A literal /www/
prefix in the request is therefore redirected (301) to the address without it — except for
existing files. Stylesheets, scripts and images on such sites are linked with the /www
prefix, and each redirect would cost an extra request. The rule goes by %{THE_REQUEST} (the
original request line), not by REQUEST_URI, which already carries the internal /www/; see
One canonical address.
Procedure: verify the headers and the /www/ rule#
- Headers:
curl -sI https://your-domain.com/— the response must containx-content-type-options: nosniffandreferrer-policy: strict-origin-when-cross-origin. - The rule:
curl -sI -o /dev/null -w '%{http_code} %{redirect_url}\n' https://your-domain.com/www/inzerce— it should return301and the address without/www. - An existing file with the prefix is not redirected:
curl -sI https://your-domain.com/www/themes/…(the path to a real file) returns200.
Without mod_headers the headers silently go missing
<IfModule> reports nothing when the module is absent. After deploying to a new server
always verify the headers with the command above.
Headers of downloaded files#
A file that passed a permission check (an invoice PDF, a complaint attachment, an import log, a grid export) must not stay in the cache of a shared proxy, a CDN or the browser on a shared computer — the authorisation would then protect only the first request.
App\Core\Utils\Helpers\PrivateDownloadHeaders::apply($response) sets
Cache-Control: no-store, private, Pragma: no-cache and Expires: 0. It is called in 9
places (Admin Invoicer InvoicePresenter, FileSystemPresenter, CsvExportTrait, Admin
Eshop ComplaintPresenter, CatalogExportDownloadTrait, ProductImportEditTrait, Front
MyOrderListPresenter, MyComplaintListPresenter, OrderDonePresenter).
PrivateDownloadHeadersTest guards the contract and a guard test walks app/ and finds a
download without the headers.
A new authorised download = call apply()
Without it the guard test fails; a missing header otherwise shows no error, only a stranger getting the file from a cache.
Files, paths and outgoing requests#
Small policies to be used wherever code touches a file or a foreign address based on input from outside.
| Policy | What it guards | Where it is enforced |
|---|---|---|
App\Core\Security\Http\AssetPathPolicy |
a legacy assetPath (a category or region image, an avatar) is a local relative path: no scheme, //host, backslash, control characters or ./.. |
the Api SaveTrait::save() for every entity with AssetPathTrait (every write path; a violation = VALIDATION_FAILED), forms through ColumnGate |
App\Core\Security\Upload\ImageUploadPolicy |
verifies an image without decoding it: type by content (jpg, png, gif, webp), a cap of 40 Mpx and a GD memory estimate (4 B/px × 1.3 + 16 MB) against the free part of memory_limit; then re-saves it with the EXIF orientation applied and without metadata (GPS gone) |
the front dropzone, the Api RasterImageProcessor (a failing file = rejected, not a 500), VariantRenderer, avatar |
App\Core\Security\Http\NavigationUrlPolicy |
a stored link to click is http(s):// with a host and without user:pass@, mailto: or tel:; where it makes sense (menu, blog intro panel) also a relative /path and #anchor — not //host; never control characters, spaces or a backslash |
Api beforeSave() (user profile, company reference, intro panel, article, menu item) and the form rule NavigationUrlPolicy::rule() |
App\Core\Security\Http\ReturnUrlPolicy |
a payment return URL (successUrl, cancelUrl, notifyUrl) goes into Location: only a relative /path, or an absolute URL with the https scheme or the request's scheme and the same host and port |
PaymentCreateTrait — a non-conforming URL is replaced by the server default |
App\Core\Security\Upload\AttachmentFilePolicy |
an attachment (Bazaar and Comcat adverts, complaints) has an extension from the allowlist (ALLOWED_EXTENSIONS) and its content per finfo is not active (HTML, SVG, XML, PHP, JS); it is stored under a random name with a single extension |
the Api rejectionReason() before writing to disk; the dropzone only gives immediate feedback |
App\Core\Security\Http\PublicIpPolicy |
the target of an outgoing request is a public address — besides private ranges also CGNAT 100.64/10, 198.18/15, NAT64, 6to4, Teredo and IPv4 hidden in IPv6 |
the e-shop image import (ImportImageDownloader) on every redirect hop |
App\Core\Utils\Helpers\AssetPathResolver::toAbsoluteWithin() |
a path from the database to a file served through a proxy: rejects .., a backslash and a null byte; the file must exist and, after realpath() (symlinks resolved), lie inside the given folder |
document PDFs through toPrivatePdfAbsolute() (root /www/private/pdf/) — Admin and Api invoices, schedules, order transitions |
App\Core\Utils\Helpers\LogRedactor |
an operational log gets the event type, error code and a correlation value, not an e-mail, tax id, message subject or token: fingerprint() = 12 characters of the SHA-256 of the normalized value, scrubMessage() strips query values, user:pass@, Authorization, Bearer/Basic, secret/token/password/key = … pairs and sk_live_… keys from an exception message |
order customer, company reservation, tax id check (VIES), payment gateways and express payments, cron calls, blocked mail (NullMailer) |
App\Core\Utils\Helpers\SafeUnserialize |
unserialize() with allowed_classes => false; an object anywhere in the data = reject the whole content |
the e-mail queue, attachments, asset variants, favourites in the session |
Latte checkUrl is not a gate for stored links
In an href="{$x}" attribute Latte blanks javascript: and data:, but only at render time:
it does not apply outside an attribute context nor to a value sent out through Api JSON, and
it lets https://foreign-server/… through. A new field with a user-supplied address is
therefore checked with NavigationUrlPolicy (a link) or AssetPathPolicy (a local file for
<img src>) before saving. For a return URL comparing the parse_url() host is not enough —
it is null also for javascript:alert(1) or https:evil.example, and everything would pass
as "relative".
unserialize() without allowed_classes fails a test
SafeUnserializeTest::testNoUnrestrictedUnserializeInApp() walks app/ and finds every
call without a class restriction. A new reader of serialized data goes through
SafeUnserialize::toArray().
fingerprint() is pseudonymization, not anonymization
The unsalted hash only pairs two log lines about the same value; a known value can be found
by comparison. Do not use it for low-entropy secrets (a PIN, a short code) and do not send it
outside the operational log. scrubMessage() is best effort — the exception text therefore
belongs in the log only, never in a public response.
The 40 Mpx cap needs memory
The memory estimate is computed against the real memory_limit. With 128M only images up
to roughly 12–15 Mpx pass (larger ones are rejected with a message); for the full cap
PHP-FPM needs about 320M. Video is verified with ffprobe — without it video cannot be
uploaded. See Deployment.
A new outgoing request to a user-supplied address
Resolve the name to an IP, check PublicIpPolicy::isPublic(), pin it for the connection
and do not follow redirects automatically — check every hop again. The pattern is
ImportImageDownloader.
Blacklist of e-mails, phones and IPs#
System → Blacklist (cms_system_blacklists) blocks an e-mail, a phone number or an IP address.
There is one check, and it runs wherever the website accepts a write from a visitor, not only at
registration.
| Part | Where | Job |
|---|---|---|
| Matching | the Api System\BlacklistManager::findBlacklistedTypes(?email, ?phone, ?ip) |
returns the blocked types (BlacklistType); e-mail case-insensitive, phone digits only (00 = +), IP as an address or a CIDR range, IPv4 and IPv6 (IpRange). A scalar read, it may run inside a save transaction |
| Save gate | BlacklistManager::refusalFor(email, phone, ip) |
a failure with the errorCode blacklisted and data.types (BlacklistRefusal); the administrator, cron and CLI are not checked (by FieldAudienceContext) |
| Wiring | BlacklistGuardTrait::blacklistRefusal() in beforeSave() of a new record |
comments, newsletter, adverts and company profiles, responses and inquiries, saved-search alerts, the contact form, discussion posts, complaints, orders; reservations in ReservationManager::book() |
| Account | UserManager::beforeSave() |
a new account: e-mail, phone and the IP from meta.ip (sent by the front; external meta is discarded); an update: only a changed e-mail or phone, so an account with a later blocked value can still save the rest of the profile |
| Front | SaveTrait (repository) passes the domain errorCode of a failure on; the form shows front.general.blacklisted instead of a generic error |
registration asks beforehand with one question (findBlacklistedTypes() → internalActionIsBlacklisted), puts the error at the e-mail or phone field and an IP as a form error; without an Api answer it does not pass (fail-closed) |
The IP is taken from the entity (the front stores it on the record, e.g. insertIp), for
registration from meta.ip. The blacklist admin form accepts only a valid address or range for
the IP type.
A new visitor form = wire the gate
A new entity created by visitors takes BlacklistGuardTrait (?BlacklistManager
$blacklistManager = null as the last constructor parameter) and calls
blacklistRefusal($email, $phone, $ip) in beforeSave() on insert. Without it the blacklist
does not apply to that path and nothing reports it.
Other safeguards and where they live#
Mechanisms outside the previous sections that you can refer to:
| Area | Mechanism |
|---|---|
| the file manager | the file manager, on recursive delete (deleteDir()) and name normalisation, unlinks / skips a symlink and does not follow it (is_link()) |
| reporting a post | a report is created only for the ID that belongs to the component; the ID from the address is only compared; reporting is AJAX-only |
| reported posts, adverts and profiles in the administration | the shared ReportModerationPresenterTrait with a server-side signal gate, reported is recalculated by ReportedFlagSyncTrait in the Api report managers |
| private file downloads | PrivateDownloadHeaders |
| writes on GET | isSameSite() on a write on GET |
| links from notifications | the bell and the overview offer a target only with the right to it |
| the notification bell | the bell shows and marks only the receiver's and the shared notifications |
| the time slot modal (My company) | SlotModal requires :Admin:Company:TimeSlot:edit on every write and checks that the calendar and slot belong to the company the modal was opened from (otherwise 403) and that the reservation belongs to the slot |
| the CORS origin list | the shared origin list is empty, localhost belongs in local.neon |
| personal data in migrations | versioned migrations and rollbacks must not contain personal data (they are in git) |
| migration prefixes | the migration-prefix guard test, see Deployment checklist |
private files in /www/private/ |
/www/private/* is in .gitignore except .htaccess |
Debug mode#
Debug is switched on explicitly; the production default is off. It is decided in
App\Bootstrap::resolveDebugMode() in this order:
- the
NETTE_DEBUGenvironment variable —1,true,on,yesturn it on; anything else including0andfalseturns it off, - the existence of the
config/debug-mode.flagfile (not in the repo, created withtouchon the dev server), - otherwise off.
Tracy switched on prints the configuration including secrets
Payment gateway keys and service credentials end up in the page source or on a bluescreen. Anyone who turns debug on in production "just for a moment" exposes them to everyone who triggers an error in that moment.
Where to look#
| I want | Where |
|---|---|
| to switch a protection on or off | config/Shared/security.neon |
| the allowed origins | config/Shared/cors.neon |
| the limits and windows | config/Shared/ratelimit.neon |
| the application key and its limits | config/Shared/api.neon (api.clientKey), app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/ |
| where the CORS headers are applied | app/Core/Traits/Api/Presenters/CorsTrait.php |
| the rate limit backends | app/Core/Security/RateLimit/ |
| the audit write | app/UI/Api/System/Models/Managers/AuthLogManager.php |
| the audit and limit cleanup | app/UI/Cron/System/Presenters/{AuthLog,RateLimit}Presenter.php |
| how debug mode is decided | app/Bootstrap.php → resolveDebugMode() |
| headers of downloaded files | app/Core/Utils/Helpers/PrivateDownloadHeaders.php |
| robot detection | app/Core/Utils/Helpers/CrawlerDetector.php |
| the content creation quota | app/Core/Security/RateLimit/ContentCreationLimiter.php, app/Core/Traits/Api/Models/Managers/ContentCreationLimitTrait.php |
| file and address policies | app/Core/Security/Http/{AssetPathPolicy,NavigationUrlPolicy,ReturnUrlPolicy,PublicIpPolicy}.php, app/Core/Security/Upload/{ImageUploadPolicy,AttachmentFilePolicy}.php, app/Core/Utils/Helpers/SafeUnserialize.php |
Follow-up chapters: Authentication and authorisation · RBAC in the API · Conditional roles · Robots and sitemaps · Scheduled tasks · Deployment checklist