Skip to content
V
For developers
Architecture, conventions, the core and security
Security / CORS, rate limiting and audit

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#

  1. 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).
  2. Add the limit and the window to config/Shared/ratelimit.neon and pass them through the RateLimitConfig constructor (the services: 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.
  3. Call hit() in the presenter (counts and decides) or check() (only asks). Hash any personal data in the key.
  4. Decide whether the limit obeys the master switch. A brake in front of a write into someone else's account should not.
  5. The throttled response must not reveal more than the passing one.
  6. Log the fact of throttling into the auth log — 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#

  1. The manager implements ContentCreationLimitedInterface and uses App\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.
  2. In beforeSave() call guardContentCreation($isInsert, '<scope>', $userId) (on an anonymous path also guardAnonymousInsert(…, $email)) and return its Result if it is not null. Only inserts count.
  3. The response is RATE_LIMIT_EXCEEDED (code 404) with the message Too many new records, try again later., with HTTP 200 on the external path.
  4. Patterns: ResponseManager (Bazaar, Comcat), MessageManager and MessageReportManager (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#

  1. Start at cms_system_auth_log filtered by IP or user_id.
  2. Compare login_fail against login_success in the same window — password guessing looks like a long series of failures from one address.
  3. Take refresh_reuse_detected seriously. A reused refresh token means somebody else has it.
  4. rate_limited says the brake worked — but not whether it was strict enough. For a key limit, meta.window tells you whether one caller is draining the application (user_minute, ip_minute) or the whole traffic is (client_*).
  5. A series of api_key_failure from one address is key guessing; meta.reason = disabled or revoked with a specific api_client_id is an application still calling after it was switched off.
  6. The Tracy log (auth) is in log/ too — it holds what was not allowed into the response: throttled password resets, audit write failures.
  7. 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.

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#

  1. Headers: curl -sI https://your-domain.com/ — the response must contain x-content-type-options: nosniff and referrer-policy: strict-origin-when-cross-origin.
  2. The rule: curl -sI -o /dev/null -w '%{http_code} %{redirect_url}\n' https://your-domain.com/www/inzerce — it should return 301 and the address without /www.
  3. An existing file with the prefix is not redirected: curl -sI https://your-domain.com/www/themes/… (the path to a real file) returns 200.

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:

  1. the NETTE_DEBUG environment variable — 1, true, on, yes turn it on; anything else including 0 and false turns it off,
  2. the existence of the config/debug-mode.flag file (not in the repo, created with touch on the dev server),
  3. 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

Cookie settings