Security verdict¶
21 checked, 3 not met, 21 declared, 0 not checked — 45 controls in the baseline.
Each control below is defined on
the security baseline, which also says what
each of the four states means. declared is not a pass.
| Control | What it checks | State, scope and what is left |
|---|---|---|
KYV-1 |
A secret an untrusted caller presents is compared in constant time and refused when it is unset — and where this extension mints it rather than taking core's or a merchant's, it carries at least 128 bits from a cryptographic random source. | declared — 2 bearer-secret surfaces over 4 mint, compare and refuse sites (2 × mint, 2 × constant-time compare, 0 × compare, 0 × refusal); 1 further entry says what the derivation reached that is not a secret: the HTTP Basic key on the api/ routes — core's own oc_api credential, read at system/library/api_gateway.php:850-881 from HTTP_AUTHORIZATION, REDIRECT_HTTP_AUTHORIZATION, PHP_AUTH_USER/PHP_AUTH_PW and apache_request_headers() in that order, and compared at api_gateway.php:948 with hash_equals against a dummy of equal length where the username is unknown. Compared in constant time at system/library/api_gateway.php:948. No refusal of an unset value sits in this extension's own code. Minted by core, so no entropy is asserted here: nothing in this baseline rests on core's own token helper.the signed-in customer's customer token — the points page compares get[customer_token] against the session's own at catalog/controller/account/reward.php:36. Compared in constant time at catalog/controller/account/reward.php:36. No refusal of an unset value sits in this extension's own code. Minted by core, so no entropy is asserted here: nothing in this baseline rests on core's own token helper. |
1.2.1 |
Store data meets markup safely where the danger is decidable — an unquoted attribute, a URL the template composed itself, a style, hand-built XML — and every store-derived subtree a template of this extension renders is written down beside the code. Beyond those two, nothing is claimed, and the page says so. |
declared — machine-pass on the sinks: 0 sink sites asserted here, 0 not admitted; attested over the inventory: 30 store-derived subtrees over 6 templates; unverified beyond it: everything else:history — oc_customer_reward.description of a row with no Loyalty label — a seam caller's or a hand-written one — decoded and escaped once in the controller. Rendered by catalog/view/template/account/reward.twig.module_loyalty_status — the module_loyalty setting. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_signup_points — the module_loyalty setting. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_birthday_points — the module_loyalty setting, cast to an integer. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_birthday_field_id — the module_loyalty setting, cast to an integer. Rendered by admin/view/template/module/loyalty.twig.birthday_fields — core's oc_custom_field_description names, as the store owner typed them. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_rate — the module_loyalty setting, a number checked on save. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_base — the module_loyalty setting, one of two fixed words. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_earn_product_override — the module_loyalty setting, cast to an integer. Rendered by admin/view/template/module/loyalty.twig.order_statuses — core's oc_order_status names, as the store owner typed them. Rendered by admin/view/template/module/loyalty.twig.earn_status_names — the same oc_order_status names, joined. Rendered by admin/view/template/module/loyalty.twig.customer_groups — core's oc_customer_group_description names, and the per-group rates of the module_loyalty setting, checked on save. Rendered by admin/view/template/module/loyalty.twig.earn_since — the module_loyalty setting, formatted as a date. Rendered by admin/view/template/module/loyalty.twig.cost — core's oc_currency symbols for the default currency, as the store owner typed them. Rendered by admin/view/template/module/loyalty.twig.stores — core's oc_store names and config_name, as the store owner typed them. Rendered by admin/view/template/module/loyalty.twig.total_loyalty_status — the total_loyalty setting. Rendered by admin/view/template/total/loyalty.twig.total_loyalty_sort_order — the total_loyalty setting, cast to an integer on save. Rendered by admin/view/template/total/loyalty.twig.module_loyalty_redeem_rate — the module_loyalty setting, refused on save unless a number above 0. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_redeem_min_points — the module_loyalty setting, refused on save unless a whole number of 0 or more. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_redeem_max_percent — the module_loyalty setting, refused on save unless a number from 0 to 100. Rendered by admin/view/template/module/loyalty.twig.symbol_left — core's oc_currency row for the session's currency, as the store owner typed it; into a JavaScript string through escape('js'). Rendered by catalog/view/template/checkout/loyalty.twig.symbol_right — the same oc_currency row. Rendered by catalog/view/template/checkout/loyalty.twig.readout — a sentence out of this extension's own language file around a figure core's currency->format() built from the same oc_currency row. Rendered by catalog/view/template/checkout/loyalty.twig.module_loyalty_expiry_enabled — the module_loyalty setting. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_expiry_months — the module_loyalty setting, held to 12 to 120 on save and cast to an integer on read. Rendered by admin/view/template/module/loyalty.twig.module_loyalty_expiry_warning_days — the module_loyalty setting, held to 14 to 365 on save and cast to an integer on read. Rendered by admin/view/template/module/loyalty.twig.greeting — the customer's own oc_customer.firstname, as they typed it, inside a sentence of this extension's language file; printed through |e. Rendered by catalog/view/template/mail/expiry.twig.store — core's config_name for the customer's store, as the store owner typed it; printed through |e. Rendered by catalog/view/template/mail/expiry.twig.store_url — core's HTTP_SERVER, or the oc_store.url of the customer's store as the store owner typed it; printed through |e. Rendered by catalog/view/template/mail/expiry.twig.api_panel — settings and database — whether the API is on, and core's own oc_api rows. Rendered by admin/view/template/module/api_panel.twig, admin/view/template/module/loyalty.twig.Unverified beyond it: the other 354 of 379 template expressions in 10 templates, and any $data subtree nobody enumerated. The inventory is an inventory and not a bound: completeness over the whole expression surface is unverifiable, so this residual is permanent, and it is published rather than left to be inferred from what is missing. |
1.2.2 |
A URL a template builds for itself, rather than taking one whole from the link helper, has every value in it URL-encoded — so nothing a store holds can add a parameter of its own or change where the link goes. | declared — 25 url attributes carrying a template expression, of 25 sink sites asserted: Every url attribute in this extension's templates takes its value whole from the link helper. |
1.2.3 |
No template expression is interpolated into a <script> element, so store data cannot end a string literal and start running. |
not met — 10 .twig files:extensions/loyalty/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> elementextensions/loyalty/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> element |
1.2.4 |
Every way this extension builds a database statement is written down beside the code, so how a value reaches a query is a published answer rather than something to go looking for. | declared — 1 way of building a statement, over 41 statements run and 53 values escaped:a statement handed over already built — the COUNT(*) PurgeCounts::total() runs is composed by its caller and arrives as a string (admin/model/customer/purge_counts.php). The remove-everything model's counts() composes two, each naming a table the declaration owns between backticks and carrying nothing a request sent; its purge() runs the statements Erasure::statements() composes (system/library/erasure.php) — the same ones a single person's erasure runs — with the subject matched against every row rather than against one cast id10 of the 41 statements are handed over already built, so what a rule reading the call site alone can see stops there; which mechanism built them is what the lines above say. |
1.2.5 |
Nothing runs a command through the shell — no backtick, no exec() — so no value a store holds can become part of one. |
checked — 83 .php files |
1.3.1 |
No screen binds a rich-text editor whose HTML this extension would then render back out, because nothing here sanitises HTML and no sanitiser ships with it. | checked — 10 .twig files |
1.3.2 |
Nothing runs code it assembled while running — no eval(), and no include of a path a variable decided. |
checked — 83 .php files |
1.5.1 |
Every XML parser is left at the restrictive default: nothing turns on external entity resolution, which is what would turn reading a spreadsheet into reading your server's files. | checked — 83 .php files |
3.2.1 |
Every route declares the response type it sets, as the code sets it, so nothing is left for a browser to re-interpret as something it is not. | declared — 11 of 21 routes set a Content-Type of their own: 6 × Content-Type: application/json, with no charset 1 × application/json, with no charset (customer/personal_data.php:272), sent as an attachment named for the person and the day (customer/personal_data.php:299) 3 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null 4 × none set, and no output written 4 × none set; the page goes out under whatever the front controller defaults to 1 × none — it returns markup to core's cart rather than writing a response 1 × none — the 404 body is plain text with no type set 1 × text/html, core's own page |
3.2.2 |
Every place a script hands a value to the page as markup rather than as text is written down beside the code, with what it puts there. | declared — 5 call sites in 2 templates, each declared with what it writes there: 2 × .append(3 × .prepend( |
3.3.1 |
A cookie this extension sets carries the Secure attribute at the call that sets it, so a browser cannot send it back over plain HTTP. |
checked — 83 .php files |
3.4.2 |
A cross-origin header is a fixed value this code chose — never a wildcard, and never the origin the caller asked for. | checked — 83 .php files |
3.5.1 |
Every route that changes something says what stands between it and a request another website caused a visitor's browser to make. | declared — 8 routes of 21 reaches a model write; the 7 admin ones among them stand behind the user_token core checks before dispatch, and 13 admin routes are gated that way in all:extensions/loyalty/src/catalog/controller/checkout/loyalty.php:40 — extension/loyalty/checkout/loyalty reaches a model write and stands behind the storefront session cookie alone; OpenCart carries no anti-CSRF token on the catalog side for it to check. |
3.5.2 |
No route grants a cross-origin caller anything, so nothing here is left depending on a browser's preflight to refuse one. | checked — 83 .php files |
3.5.3 |
A route that writes refuses a request that is not a POST, so a link somebody follows cannot make the change on their behalf. |
not met — 83 .php files:extensions/loyalty/src/admin/controller/customer/personal_data.php:321 — PersonalData::grant() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/customer/purge.php:147 — Purge::remove() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/module/loyalty.php:292 — Loyalty::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/module/loyalty.php:390 — Loyalty::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/module/loyalty.php:523 — Loyalty::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/total/loyalty.php:84 — Loyalty::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/admin/controller/total/loyalty.php:125 — Loyalty::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/loyalty/src/catalog/controller/checkout/loyalty.php:40 — Loyalty::index() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does |
4.1.1 |
A response carrying a body says what that body is, and the route table records the Content-Type each route sets rather than the one it ought to. |
declared — 11 of 21 routes set a Content-Type of their own: 6 × Content-Type: application/json, with no charset 1 × application/json, with no charset (customer/personal_data.php:272), sent as an attachment named for the person and the day (customer/personal_data.php:299) 3 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null 4 × none set, and no output written 4 × none set; the page goes out under whatever the front controller defaults to 1 × none — it returns markup to core's cart rather than writing a response 1 × none — the 404 body is plain text with no type set 1 × text/html, core's own page |
5.2.1 |
An upload is accepted on the server's terms — what the bytes are, not what the caller said they were — and every surface that takes one is declared. | declared — 0 upload surfaces across 21 routes |
5.2.2 |
An uploaded file is stored under a name the server chose, so nothing the caller named decides where it lands. | declared — 0 upload surfaces across 21 routes |
5.3.1 |
Every file this extension writes says whether a browser can fetch it, and nothing it writes where a browser can reach is program code. | declared — 10 write sites, 3 of them fetchable by a browser:system/library/diary.php:437 — kyvero.log in the store's own log directory — the DIR_LOGS this class is handed, with no part of the name coming from a request — one record appended per write, at system/library/diary.php:437system/library/diary.php:470 — the same kyvero.log, opened r+ to trim it back under the 1 MiB cap, at system/library/diary.php:470system/library/diary.php:495 — the same kyvero.log, rewritten to what a trim kept — oldest-first, on a line boundary, under an exclusive non-blocking lock — at system/library/diary.php:495 |
5.3.2 |
Every path this extension writes to is written down beside the code, with where the name in it came from. | declared — 10 write sites, each declared with its file:line and pinned against the token stream both ways:3 × log file 7 × stream |
6.2.6 |
A field that takes a password or a key is masked, so it is not left readable on the screen or in a screenshot of it. | checked — 10 .twig files |
6.2.7 |
A masked field does not refuse a paste or shut a password manager out of it. | checked — 10 .twig files |
6.3.2 |
No credential is written into the source — no default account, and no password or key a reader of the shipped files could use. | checked — 83 .php files |
8.1.1 |
Every route the extension answers is written down beside the code, with what guards it — and the gate refuses a route nobody wrote down and a written-down route nothing answers. | declared — 21 routes: 13 admin, 8 catalog, each declared beside the code |
8.2.1 |
An admin route that changes something tests the permission itself, in a condition that can refuse — and a route that only reads says so, standing behind the check OpenCart makes before dispatch. | declared — 13 admin routes: 10 pin a permission themselves, 0 at one same-class hop, 0 at two (the hop ceiling), 3 unpinned:extensions/loyalty/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/loyalty/customer/purge before dispatch. No model write is reachable from it.extensions/loyalty/src/admin/controller/module/loyalty.php:198 — Loyalty::index() pins no permission of its own; core checks access on extension/loyalty/module/loyalty before dispatch. No model write is reachable from it.extensions/loyalty/src/admin/controller/total/loyalty.php:33 — Loyalty::index() pins no permission of its own; core checks access on extension/loyalty/total/loyalty before dispatch. No model write is reachable from it. |
8.2.2 |
A storefront route that reaches a record says which caller may reach which records, and what selects one — so reaching somebody else's is a question with a written answer. | declared — 9 triples over 7 of 8 catalog routes; the admin half is one line on the shared page:extension/loyalty/api/gateway.fail — a holder of one of core's oc_api credentials: Absent, unknown, duplicated, disabled, wrong-keyed and address-not-listed are one refusal with one code and one message, so the envelope partitions nothing. Selected by none — not a record; this route renders a refusal and reads nothingextension/loyalty/api/v1/movement — a holder of one of core's oc_api credentials: The credential cannot be scoped. A movement carries a customer id, an order id and points — no name, address or contact detail, which are core's — and its claim key, which can name a customer by number, is never selected. Selected by movement_idextension/loyalty/api/v1/movement — a holder of one of core's oc_api credentials, walking the collection: A filter is a convenience for a caller who wants a narrower read, not a boundary: the same credential may simply leave it off. Selected by none — not a record; the filters and the cursor are a page of a walk rather than a record selectorextension/loyalty/api/v1/balance — a holder of one of core's oc_api credentials: The credential cannot be scoped: every customer account's points are readable. A balance carries a customer id and figures only; the customer's name and address are core's, on core's customer record, and are not read. Selected by opencart_customer_idextension/loyalty/api/v1/balance — a holder of one of core's oc_api credentials, walking the collection: A filter is a convenience for a caller who wants a narrower read, not a boundary: the same credential may simply leave it off. Selected by none — not a record; the filters and the cursor are a page of a walk rather than a record selectorextension/loyalty/cron/loyalty — OpenCart's own scheduler, or a terminal on the store: There is no secret for this door: the proof is the framework passing an argument, or the absence of a request altogether, so a caller over HTTP arrives with zero under a web SAPI and is refused before anything is written. Selected by none — not a record; the route reads no request key at all and passes over every storeextension/loyalty/checkout/loyalty — the signed-in customer, by $this->customer: The control offers the signed-in customer their own points and nobody else's. Selected by none — not a record; the balance is core's own getRewardPoints() for the customer the session is signed in as, and nothing is read from the requestextension/loyalty/checkout/loyalty.save — the signed-in customer, by $this->customer: A customer chooses how many of their own points to spend, and cannot choose more than they hold. Selected by none — not a record; loyalty is a number of points, clamped to what the signed-in customer's own balance and cart allow, and written to their own sessionextension/loyalty/account/reward — the signed-in customer, by $this->customer: The points page shows the signed-in customer their own points and nobody else's. Selected by none — not a record; the history and the summary are read for $this->customer->getId(), and nothing in the request names a customer |
8.3.1 |
What bounds a caller to their own records comes from the server — a session, a stored row, the store id — and never from a value the caller supplied. | declared — 5 distinct bounds, each named by the triple it scopes: bounded by nothing — an OpenCart API user opens every store in the installation, and this resource does not narrow that bounded by nothing — a filter is the caller narrowing their own read, never the store narrowing what they may see bounded by nothing — the refusal is the same for every caller bounded by the $cron_id argument core's dispatcher supplies — or the cli SAPI, which no request can present either; nothing in the request scopes anythingbounded by the session's own customer |
9.1.1 |
A secret that carries its own claim — an identity inside the string rather than a row to look up — is only believed after the signature beside it has been checked. | declared — 0 self-contained surfaces of 2 bearer-secret surfaces |
9.1.2 |
Every hashing algorithm is a literal in the source, from a fixed allowlist, so nothing arriving in a request can choose a weaker one. | checked — 83 .php files |
9.1.3 |
The key a signed secret is checked against comes from somewhere this extension was configured with, never from anything inside the secret itself. | declared — 2 bearer-secret surfaces, from core — never from anything inside the secret presented:system/library/api_gateway.php:948 — corecatalog/controller/account/reward.php:36 — core — minted by core's own login, and this extension only carries it back on its links |
9.2.1 |
A secret that carries its own expiry is accepted only inside it, and the declaration says which ones carry one. | declared — 0 surfaces of 2 bearer-secret surfaces could carry a validity span inside the secret itself; the rest are a reference to a row, whose expiry is a column on it rather than a claim the caller presents: No secret this extension accepts carries its own validity span. |
11.3.1 |
Nothing encrypts with a broken mode or padding — no ECB, no PKCS#1 v1.5. | checked — 83 .php files |
11.3.2 |
Where anything is encrypted, the cipher is a literal in the source from a short allowlist, so nothing arriving in a request can choose a weaker one. | checked — 83 .php files |
11.4.1 |
Every hash this extension computes is written down with what it is for, so a hash naming a cache entry is not read as one standing in front of a secret. | declared — 0 hash uses over 0 calls to 0 hash functions: This extension computes no hash. |
12.1.1 |
No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. | checked — 83 .php files |
12.2.1 |
An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. | checked — 83 .php files |
12.2.2 |
An outbound request trusts your server's own certificate store: nothing here bundles a certificate authority of its own or turns verification off. | checked — 83 .php files |
14.2.1 |
A credential is not carried in a URL, where a browser history, a referrer header and a proxy log each keep their own copy of it. | not met — 1 bearer-secret surface of 2 travels in a URL:catalog/controller/account/reward.php:36 — the URL query string of the account pages. The test is core's own from its Reward Points page, compared with hash_equals rather than core's loose != |
14.3.1 |
Nothing is left behind in the browser's own storage for the next person at that computer to read. | checked — 10 .twig files |
15.2.1 |
The extension bundles no third-party library, so there is nothing inside it for you to keep patched other than our own code. | checked — 83 .php files |
15.3.1 |
What reaches a page is an enumerated set of values rather than whole database rows handed over wholesale, and every one of them is written down. | declared — 30 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:history — oc_customer_reward.description of a row with no Loyalty label — a seam caller's or a hand-written one — decoded and escaped once in the controllermodule_loyalty_status — the module_loyalty settingmodule_loyalty_earn_signup_points — the module_loyalty settingmodule_loyalty_earn_birthday_points — the module_loyalty setting, cast to an integermodule_loyalty_earn_birthday_field_id — the module_loyalty setting, cast to an integerbirthday_fields — core's oc_custom_field_description names, as the store owner typed themmodule_loyalty_earn_rate — the module_loyalty setting, a number checked on savemodule_loyalty_earn_base — the module_loyalty setting, one of two fixed wordsmodule_loyalty_earn_product_override — the module_loyalty setting, cast to an integerorder_statuses — core's oc_order_status names, as the store owner typed themearn_status_names — the same oc_order_status names, joinedcustomer_groups — core's oc_customer_group_description names, and the per-group rates of the module_loyalty setting, checked on saveearn_since — the module_loyalty setting, formatted as a datecost — core's oc_currency symbols for the default currency, as the store owner typed themstores — core's oc_store names and config_name, as the store owner typed themtotal_loyalty_status — the total_loyalty settingtotal_loyalty_sort_order — the total_loyalty setting, cast to an integer on savemodule_loyalty_redeem_rate — the module_loyalty setting, refused on save unless a number above 0module_loyalty_redeem_min_points — the module_loyalty setting, refused on save unless a whole number of 0 or moremodule_loyalty_redeem_max_percent — the module_loyalty setting, refused on save unless a number from 0 to 100symbol_left — core's oc_currency row for the session's currency, as the store owner typed it; into a JavaScript string through escape('js')symbol_right — the same oc_currency rowreadout — a sentence out of this extension's own language file around a figure core's currency->format() built from the same oc_currency rowmodule_loyalty_expiry_enabled — the module_loyalty settingmodule_loyalty_expiry_months — the module_loyalty setting, held to 12 to 120 on save and cast to an integer on readmodule_loyalty_expiry_warning_days — the module_loyalty setting, held to 14 to 365 on save and cast to an integer on readgreeting — the customer's own oc_customer.firstname, as they typed it, inside a sentence of this extension's language file; printed through |estore — core's config_name for the customer's store, as the store owner typed it; printed through |estore_url — core's HTTP_SERVER, or the oc_store.url of the customer's store as the store owner typed it; printed through |eapi_panel — settings and database — whether the API is on, and core's own oc_api rows |
16.2.5 |
No log line names a credential — no token, secret, signature or password is written into the file the error log screen renders. | checked — 83 .php files |
16.4.1 |
Everything written to the error log is escaped first, so nothing a store holds can forge a record or close the box a merchant reads the log in. | checked — 83 .php files |
16.5.1 |
No error message carrying internal detail — a database driver puts the failing statement in one — is thrown onward or rendered to a response. | checked — 83 .php files |