Security verdict¶
18 checked, 7 not met, 20 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. | not met — 6 bearer-secret surfaces over 29 mint, compare and refuse sites (9 × mint, 3 × constant-time compare, 1 × compare, 16 × refusal); 3 further entries say what the derivation reached that is not a secret: the secret query parameter on both scheduled routes — extension/wishlist/cron/wishlist and extension/wishlist/cron/wishlist.purge — minted at system/library/secret.php:55 as bin2hex(random_bytes(32)), 64 hexadecimal characters and 256 bits; compared at system/library/cron_access.php:110 with hash_equals, and refused at cron_access.php:100 when the stored secret is empty and, on the line below it, when the parameter is not a string, which is what ?secret[]= arrives as. Compared in constant time at system/library/cron_access.php:110. Refused when unset at admin/controller/module/wishlist.php:229, admin/controller/module/wishlist.php:230, system/library/cron_access.php:100. Minted with 256 bits of CSPRNG output at system/library/secret.php:55, which meets the 128-bit floor.the guest cookie — every catalog/account/wishlist route reads wishlist_guest off the request and treats it as the whole of a guest's authority over their saved products (catalog/model/account/wishlist.php:643, shape-checked at system/library/guest.php:95). No comparison of it happens in this extension's own code. Refused when unset at system/library/guest.php:95. Minted with 128 bits of CSPRNG output at catalog/model/account/wishlist.php:616, which meets the 128-bit floor.the share link — extension/wishlist/account/share and .cart read get[share_token] (and post on the cart route) and resolve it through wishlist_share (catalog/controller/account/share.php:46 and :206, shape-checked at system/library/share.php:79). No comparison of it happens in this extension's own code. Refused when unset at system/library/share.php:79. Minted with 128 bits of CSPRNG output at catalog/model/account/share.php:133, catalog/model/account/share.php:165, which meets the 128-bit floor.the unsubscribe link — extension/wishlist/account/unsubscribe reads get[unsubscribe] and verifies it with hash_equals() at system/library/unsubscribe.php:153. Compared in constant time at system/library/unsubscribe.php:153. No refusal of an unset value sits in this extension's own code. Minted with 256 bits of CSPRNG output at admin/model/module/wishlist.php:91, which meets the 128-bit floor.the API credential — ApiGateway compares the presented key against the enabled oc_api row with hash_equals() at system/library/api_gateway.php:948, spending the comparison against an equal-length dummy where there is no row, so an absent, unknown or disabled credential does not answer faster than a wrong key. 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 shopper's customer token — every catalog/account/wishlist page compares get[customer_token] against the session's own at catalog/controller/account/wishlist.php:702. Compared at catalog/controller/account/wishlist.php:702 with an operator rather than in constant time. 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: 70 store-derived subtrees over 15 templates; unverified beyond it: everything else:module_wishlist_price_alert — setting. Rendered by admin/view/template/module/wishlist.twig.module_wishlist_stock_alert — setting. Rendered by admin/view/template/module/wishlist.twig.module_wishlist_alert_limit — setting. Rendered by admin/view/template/module/wishlist.twig.module_wishlist_guest_retention — setting. Rendered by admin/view/template/module/wishlist.twig.module_wishlist_module_limit — setting, held to 1..12 by Configuration::sanitise(). Rendered by admin/view/template/module/wishlist.twig.report_wishlist_status — setting. Rendered by admin/view/template/report/wishlist_form.twig.report_wishlist_sort_order — setting. Rendered by admin/view/template/report/wishlist_form.twig.version_warning — the store's OpenCart release, through Compatibility. Rendered by admin/view/template/module/wishlist.twig.cron_scheduler_dead — the store's OpenCart release, through Compatibility — whether this release's own scheduler can run at all. Rendered by admin/view/template/module/wishlist.twig.sweep_url — setting — the stored scheduled-route secret, in a URL built from HTTP_CATALOG and the hourly route; empty on a store that has no secret. Rendered by admin/view/template/module/wishlist.twig.purge_url — the same secret in the same shape, against the nightly route. Rendered by admin/view/template/module/wishlist.twig.text_door_url — both of those URLs, inside one language string; the placeholder wording where there is no secret. Rendered by admin/view/template/module/wishlist.twig.text_door_cli — DIR_OPENCART — the store's own directory, inside a language string. Rendered by admin/view/template/module/wishlist.twig.rotate — a link to this screen's own rotate route, carrying the admin session token. Rendered by admin/view/template/module/wishlist.twig.version_danger — the store's OpenCart release, where it is below the floor this extension installs on. Rendered by admin/view/template/module/wishlist.twig.copy_panel — rendered partial over the merchant's own storefront wording, per language. Rendered by admin/view/template/module/wishlist.twig.copy — the merchant's stored wording overrides beside this extension's shipped defaults. Rendered by admin/view/template/module/copy_panel.twig.language_readout — rendered partial over oc_language. Rendered by admin/view/template/module/wishlist.twig.resolution — rendered from oc_language, through LanguageReadout. Rendered by admin/view/template/module/language_readout.twig.languages — database — core's oc_language, unfiltered. Rendered by admin/view/template/module/copy_panel.twig.api_panel — rendered partial over the store's own API state. Rendered by admin/view/template/module/wishlist.twig.api — database and settings — the store's base URL, whether the API is on, the declared versions, and core's oc_api usernames with whether each has a user and an IP allowed. The credential list carries usernames and never a key. Rendered by admin/view/template/module/api_panel.twig.stores — database — core's oc_store, with store 0 named from the store's own config_name. Rendered by admin/view/template/customer/wishlist.twig, admin/view/template/report/wishlist.twig.store — database — the store a row belongs to, and on the mail and the unsubscribe page the store's own name. Rendered by admin/view/template/customer/wishlist.twig, admin/view/template/report/wishlist.twig, catalog/view/template/account/unsubscribe.twig, catalog/view/template/mail/alert.twig.items — database — one customer's saved products, named and priced out of core's catalogue. Rendered by admin/view/template/customer/wishlist.twig.summary — database — counts of that customer's saved products per store. Rendered by admin/view/template/customer/wishlist.twig.add — a link to this extension's own add route, carrying the admin session token. Rendered by admin/view/template/customer/wishlist.twig.remove — a link to this extension's own remove route, carrying the admin session token. Rendered by admin/view/template/customer/wishlist.twig.user_token — the admin session token, rendered into the report screen's own JavaScript URLs. Rendered by admin/view/template/report/wishlist.twig.list — a link to this extension's own list route, carrying the admin session token and the current filter. Rendered by admin/view/template/report/wishlist.twig.wishlisted_by — the three audiences the report can be filtered to, named from the language file. Rendered by admin/view/template/report/wishlist.twig.filter_name — request — get[filter_name]. Rendered by admin/view/template/report/wishlist.twig.filter_store_id — request — get[filter_store_id], cast to int. Rendered by admin/view/template/report/wishlist.twig.filter_date_from — request — get[filter_date_from]. Rendered by admin/view/template/report/wishlist.twig.filter_date_to — request — get[filter_date_to]. Rendered by admin/view/template/report/wishlist.twig.filter_wishlisted_by — request — get[filter_wishlisted_by], narrowed to the three audiences. Rendered by admin/view/template/report/wishlist.twig.filter_out_of_stock — request — get[filter_out_of_stock], cast to int. Rendered by admin/view/template/report/wishlist.twig.products — database — on the report, the demand ranking over oc_customer_wishlist and wishlist_guest_item joined to core's catalogue; on the share page, a row per saved product ending in a whole getProduct() row merged in at catalog/controller/account/share.php:152, so every oc_product and oc_product_description column is under it. Rendered by admin/view/template/report/wishlist_list.twig, catalog/view/template/account/share.twig.sort — request — get[sort], narrowed by Demand::sort() to the columns it names itself. Rendered by admin/view/template/report/wishlist_list.twig.order — request — get[order], narrowed to ASC or DESC. Rendered by admin/view/template/report/wishlist_list.twig.sorts — the sort links for each column, carrying the admin session token and the current filter. Rendered by admin/view/template/report/wishlist_list.twig.results — a row count, into a language string. Rendered by admin/view/template/report/wishlist_list.twig.price — database — a product's price, formatted in the shopper's own currency, or false where the store hides prices from signed-out visitors. Rendered by catalog/view/template/account/share.twig, catalog/view/template/account/wishlist_alert.twig, catalog/view/template/mail/alert.twig.stock — database — a product's stock status as core names it. Rendered by catalog/view/template/account/wishlist_alert.twig, catalog/view/template/mail/alert.twig.title — the shared list's heading, a language string carrying no name — a share link names nobody. Rendered by catalog/view/template/account/share.twig.missing — whether the token named no live share row. Rendered by catalog/view/template/account/share.twig.bulk — rendered partial holding the add-all control. Rendered by catalog/view/template/account/share.twig.bulk_action — a link to the cart route, carrying the signed-in shopper's own customer token where there is one. Rendered by catalog/view/template/account/wishlist_cart.twig.bulk_cart — a link to core's own common/cart.info. Rendered by catalog/view/template/account/wishlist_cart.twig.bulk_token — the share token itself, rendered into the shared page's own form so the add-all posts it back; empty on the shopper's own wishlist page. Rendered by catalog/view/template/account/wishlist_cart.twig.alert_price — database — whether this customer asked about price drops, per product. Rendered by catalog/view/template/account/wishlist_alert.twig.alert_stock — database — whether this customer asked about restocks, per product. Rendered by catalog/view/template/account/wishlist_alert.twig.alert_guest — whether the reader is a guest, who has no alerts to set. Rendered by catalog/view/template/account/wishlist_alert.twig.alert_action — a link to this extension's own alert route, carrying the shopper's own customer token. Rendered by catalog/view/template/account/wishlist_alert.twig.alert_products — database — the saved products the alert panel offers switches for, named out of core's catalogue. Rendered by catalog/view/template/account/wishlist_alert.twig.share_link — database — the shopper's own share URL, carrying their own 32-character share token. Rendered by catalog/view/template/account/wishlist_share.twig.share_off — database — whether this shopper has a live share row. Rendered by catalog/view/template/account/wishlist_share.twig.share_action — a link to this extension's own share route, carrying the shopper's own customer token. Rendered by catalog/view/template/account/wishlist_share.twig.done — whether the unsubscribe code verified and the marker row was written. Rendered by catalog/view/template/account/unsubscribe.twig.wishlist — a link back to the storefront wishlist page, in the store's own language. Rendered by catalog/view/template/account/unsubscribe.twig, catalog/view/template/mail/alert.twig.all — a link to the storefront wishlist page, carrying the signed-in shopper's own customer token where there is one. Rendered by catalog/view/template/module/wishlist.twig.home — a link back to the storefront home page. Rendered by catalog/view/template/account/share.twig.store_url — setting — the store's own URL, concatenated rather than built with Url::link() because the sweep runs at store 0. Rendered by catalog/view/template/mail/alert.twig.logo — setting — the store's own logo, as an absolute URL. Rendered by catalog/view/template/mail/alert.twig.observed — database — the products that moved, with what they moved from and to. Rendered by catalog/view/template/mail/alert.twig.manage — a link to the shopper's own wishlist page. Rendered by catalog/view/template/mail/alert.twig.unsubscribe — the signed opt-out URL for this customer and store, empty where the store has no alert secret. Rendered by catalog/view/template/mail/alert.twig.text_greeting — the merchant's own wording, with the customer's first name in it. Rendered by catalog/view/template/mail/alert.twig.text_unsubscribe — the merchant's own wording for the opt-out line. Rendered by catalog/view/template/mail/alert.twig.text_wishlist — the merchant's own wording for the wishlist link, and the header counter's label. Rendered by catalog/view/template/mail/alert.twig, catalog/view/template/account/unsubscribe.twig.Unverified beyond it: the other 433 of 480 template expressions in 19 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 — 47 url attributes carrying a template expression, of 47 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 — 19 .twig files:extensions/wishlist/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> elementextensions/wishlist/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> elementextensions/wishlist/src/admin/view/template/report/wishlist.twig:109 — {{ user_token }} is interpolated inside a <script> elementextensions/wishlist/src/admin/view/template/report/wishlist.twig:123 — {{ user_token }} 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 — 3 ways of building a statement, over 98 statements run and 41 values escaped:escaped literal: every value this extension puts in a statement — Through $this->db->escape(), or an (int) cast where the value is numeric. The guest and share tokens are escaped the same way any other string is, after Guest::valid() and Share::valid() have narrowed them to 32 alphanumerics.interpolated identifier: the table name in the installer's SHOW TABLES / SHOW COLUMNS / SHOW INDEX probes, and in Schema's CREATE TABLE statements — DB_PREFIX concatenated with a table name out of Schema's own constant list, at admin/model/module/wishlist.php:180-221. Nothing from the request reaches it.whole-clause concatenation: the demand report and the API collections — The report assembles its SELECT, UNION, JOIN, WHERE and ORDER BY as whole clauses (admin/model/report/wishlist.php:80-100). The ORDER BY column comes from Demand::sort(), which answers one of the expressions it names itself; the API filter concatenates a column name it chose from the contract's own parameter map (catalog/model/api/wishlist.php:290) and an integer.13 of the 98 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 — 96 .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 — 19 .twig files |
1.3.2 |
Nothing runs code it assembled while running — no eval(), and no include of a path a variable decided. |
not met — 96 .php files:extensions/wishlist/src/system/library/copy.php:392 — require runs a PHP file whose path is decided while running, which is code execution the source does not name |
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 — 96 .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 — 17 of 40 routes set a Content-Type of their own: 1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="most-wishlisted- 7 × application/json 1 × application/json — set by extension/wishlist/account/wishlist.cart, which this route hands the rows to 1 × application/json — the two new scheduled addresses, which is the one response in this extension that carries a live credential back to a browser 1 × application/json, with no charset (customer/personal_data.php:230) 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) 1 × application/json, with no charset (customer/personal_data.php:359) 1 × application/json, with no charset (customer/purge.php:168) 2 × application/json; charset=utf-8 — computed rather than literal, by ApiAnswer::headers() 1 × application/json; charset=utf-8 — computed rather than literal, by ApiAnswer::headers(), beside X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null 1 × none — a 404 status line on a code that verifies against nothing, and otherwise the store's default 1 × none — a 404 status line where the token names nothing, and otherwise the store's default 2 × none — a 404 status line with a plain-text body on refusal, and nothing at all on a run 1 × none — index() returns its markup to the position that asked and writes no output, so a direct request renders an empty page 1 × none — it answers the rendered list fragment, so the store's default stands 2 × none — it redirects back to the wishlist page 1 × none — it returns a boolean to the sweep rather than writing a response 13 × none — nothing sets a Content-Type, so the store's default stands 1 × none. The 404 body is plain text with no type set; an admitted run prints its counts to the terminal and writes its pass line to the diary |
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 — 11 call sites in 3 templates, each declared with what it writes there: 5 × .append(6 × .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. |
not met — 96 .php files:extensions/wishlist/src/catalog/model/account/wishlist.php:637 — setcookie() names no secure attribute of its own and no __Secure- prefix, so nothing at this call says the cookie may not travel over plain HTTP |
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 — 96 .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 — 18 routes of 40 reaches a model write; the 11 admin ones among them stand behind the user_token core checks before dispatch, and 22 admin routes are gated that way in all:extensions/wishlist/src/catalog/controller/account/wishlist.php:70 — extension/wishlist/account/wishlist 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.extensions/wishlist/src/catalog/controller/account/wishlist.php:128 — extension/wishlist/account/wishlist.list 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.extensions/wishlist/src/catalog/controller/account/wishlist.php:438 — extension/wishlist/account/wishlist.alert 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.extensions/wishlist/src/catalog/controller/account/wishlist.php:589 — extension/wishlist/account/wishlist.add 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.extensions/wishlist/src/catalog/controller/account/wishlist.php:634 — extension/wishlist/account/wishlist.remove 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.extensions/wishlist/src/catalog/controller/cli/wishlist.php:83 — extension/wishlist/cli/wishlist 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.extensions/wishlist/src/catalog/controller/cron/wishlist.php:94 — extension/wishlist/cron/wishlist.purge 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 — 96 .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 — 96 .php files:extensions/wishlist/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/wishlist/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/wishlist/src/admin/controller/customer/wishlist.php:133 — Wishlist::add() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/customer/wishlist.php:177 — Wishlist::remove() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/module/wishlist.php:288 — Wishlist::rotate() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/module/wishlist.php:337 — Wishlist::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/module/wishlist.php:444 — Wishlist::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/module/wishlist.php:611 — Wishlist::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/report/wishlist.php:99 — Wishlist::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/report/wishlist.php:144 — Wishlist::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/admin/controller/report/wishlist.php:178 — Wishlist::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/account/wishlist.php:70 — Wishlist::index() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/account/wishlist.php:128 — Wishlist::list() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/account/wishlist.php:438 — Wishlist::alert() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/account/wishlist.php:589 — Wishlist::add() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/account/wishlist.php:634 — Wishlist::remove() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/cli/wishlist.php:83 — Wishlist::index() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST doesextensions/wishlist/src/catalog/controller/cron/wishlist.php:94 — Wishlist::purge() 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 — 17 of 40 routes set a Content-Type of their own: 1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="most-wishlisted- 7 × application/json 1 × application/json — set by extension/wishlist/account/wishlist.cart, which this route hands the rows to 1 × application/json — the two new scheduled addresses, which is the one response in this extension that carries a live credential back to a browser 1 × application/json, with no charset (customer/personal_data.php:230) 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) 1 × application/json, with no charset (customer/personal_data.php:359) 1 × application/json, with no charset (customer/purge.php:168) 2 × application/json; charset=utf-8 — computed rather than literal, by ApiAnswer::headers() 1 × application/json; charset=utf-8 — computed rather than literal, by ApiAnswer::headers(), beside X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null 1 × none — a 404 status line on a code that verifies against nothing, and otherwise the store's default 1 × none — a 404 status line where the token names nothing, and otherwise the store's default 2 × none — a 404 status line with a plain-text body on refusal, and nothing at all on a run 1 × none — index() returns its markup to the position that asked and writes no output, so a direct request renders an empty page 1 × none — it answers the rendered list fragment, so the store's default stands 2 × none — it redirects back to the wishlist page 1 × none — it returns a boolean to the sweep rather than writing a response 13 × none — nothing sets a Content-Type, so the store's default stands 1 × none. The 404 body is plain text with no type set; an admitted run prints its counts to the terminal and writes its pass line to the diary |
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 40 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 40 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 — 19 .twig files |
6.2.7 |
A masked field does not refuse a paste or shut a password manager out of it. | checked — 19 .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. | not met — 96 .php files:extensions/wishlist/src/system/library/configuration.php:414 — module_wishlist_alert_secret is given a literal in the source, so the shipped files carry the credential |
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 — 40 routes: 22 admin, 18 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 — 22 admin routes: 14 pin a permission themselves, 0 at one same-class hop, 0 at two (the hop ceiling), 8 unpinned:extensions/wishlist/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/wishlist/customer/purge before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/customer/wishlist.php:51 — Wishlist::index() pins no permission of its own; core checks access on extension/wishlist/customer/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/module/wishlist.php:125 — Wishlist::index() pins no permission of its own; core checks access on extension/wishlist/module/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/report/wishlist.php:59 — Wishlist::index() pins no permission of its own; core checks access on extension/wishlist/report/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/report/wishlist.php:196 — Wishlist::report() pins no permission of its own; core checks access on extension/wishlist/report/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/report/wishlist.php:229 — Wishlist::list() pins no permission of its own; core checks access on extension/wishlist/report/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/report/wishlist.php:243 — Wishlist::getReport() pins no permission of its own; core checks access on extension/wishlist/report/wishlist before dispatch. No model write is reachable from it.extensions/wishlist/src/admin/controller/report/wishlist.php:343 — Wishlist::export() pins no permission of its own; core checks access on extension/wishlist/report/wishlist 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 — 25 triples over 18 of 18 catalog routes; the admin half is one line on the shared page:extension/wishlist/account/share — whoever holds the share link, signed in or not: One shopper's saved products per token, and the token is the whole of the authority — nothing else identifies the reader. Selected by get[share_token]extension/wishlist/account/share.cart — whoever holds the share link, signed in or not: It adds the products of the list that token names, into the holder's own cart. Selected by post[share_token], falling back to get[share_token]extension/wishlist/account/unsubscribe — the customer the code names, who need not be signed in: A code switches off the alerts of the one customer it names on the one store it names, and a code whose pair has been edited verifies against nothing. Selected by get[unsubscribe]extension/wishlist/account/wishlist — a signed-in customer: A signed-in shopper sees their own saved products on this storefront, and no other account's. Selected by none — not a record; the customer is taken from the session and never from the requestextension/wishlist/account/wishlist — a guest: A guest sees what the browser holding that cookie saved on this storefront; the cookie is the whole of the authority. Selected by none — not a record; the guest token is read from the wishlist_guest cookie, never from a request parameterextension/wishlist/account/wishlist.list — a signed-in customer: The fragment lists that account's own saved products. Selected by none — not a record; the customer is taken from the sessionextension/wishlist/account/wishlist.list — a guest: The fragment lists what that cookie's browser saved. Selected by none — not a record; the guest token is read from the wishlist_guest cookieextension/wishlist/account/wishlist.cart — the shopper's own session, signed in or not: It adds to the cart of whoever called it, from the list that caller can already see. Selected by none — not a record; addressed over HTTP it reads nothing and adds the caller's own listextension/wishlist/account/wishlist.alert — a signed-in customer: A shopper switches their own alerts on or off, on the storefront they are on. Selected by none — not a record; the customer is taken from the sessionextension/wishlist/account/wishlist.alert — a guest: A guest is sent back to the page without a write. Selected by none — not a record; nothing is read and nothing is writtenextension/wishlist/account/wishlist.share — a signed-in customer, or a guest holding the wishlist_guest cookie: A shopper mints, rotates or withdraws the link to their own list, and can address nobody else's. Selected by none — not a record; post[action] chooses between minting, rotating and switching off, and never names a rowextension/wishlist/account/wishlist.add — a signed-in customer: A shopper saves a product this storefront publishes, onto their own list. Selected by post[product_id]extension/wishlist/account/wishlist.add — a guest: A guest saves onto the list their own cookie names; the row is created with the cookie. Selected by post[product_id]extension/wishlist/account/wishlist.remove — a signed-in customer: A shopper removes a product from their own list; a product id of somebody else's row matches nothing. Selected by get[product_id], falling back to post[product_id]extension/wishlist/account/wishlist.remove — a guest: A guest removes from the list their own cookie names. Selected by get[product_id], falling back to post[product_id]extension/wishlist/api/gateway.fail — any caller, authenticated or not: It answers a refusal envelope and names no row. Selected by none — not a record; it reads only the verdict the gateway left in the registryextension/wishlist/api/v1/watch — the API credential, which is the merchant rather than a shopper: A credential that can read this resource can read all of it; the id narrows the answer, it does not fence it. Selected by the watch_id in the query string, where one is givenextension/wishlist/api/v1/demand — the API credential, which is the merchant rather than a shopper: A credential that can read this resource reads the whole ranking; no row here identifies anybody. Selected by the product id in the query string, where one is givenextension/wishlist/cron/wishlist — the store's own scheduler: It runs the alert sweep for the whole install, which is what a scheduled job is. Selected by none — not a record; $cron_id names the oc_cron row that dispatched, never a row this extension readsextension/wishlist/cron/wishlist — whoever holds the scheduled-route secret: The same sweep the scheduler drives, for a merchant whose host gives them a cron panel and no shell. It partitions nothing per identity because there is one identity: the store's own schedule. Selected by none — not a record; get[secret] is the whole credential and addresses nothingextension/wishlist/cron/wishlist.purge — the store's own scheduler: It removes guests nobody has come back for, a batch at a time. Selected by none — not a record; $cron_id names the oc_cron row that dispatched, never a row this extension readsextension/wishlist/cron/wishlist.purge — whoever holds the scheduled-route secret: The same purge the scheduler drives, for a merchant with a cron panel and no shell. Selected by none — not a record; get[secret] is the whole credential and addresses nothingextension/wishlist/cli/wishlist — whoever can run PHP on the server: Reached through extension/wishlist/wishlist.php, and refused outright to anything that is not a terminal. It drives the same two model methods the scheduled routes drive and does not reach through those routes. Selected by none — not a record. The command takes exactly one argument and it names a pass rather than a row: sweep or purge, and anything else is refused with a usage message and a non-zero statusextension/wishlist/mail/alert — the customer the digest names: It renders and sends one customer's mail, and reads nothing from the request to decide whose. Selected by none — not a record; the digest is an argument, and an HTTP caller supplies noneextension/wishlist/module/wishlist — any visitor, signed in or not: Any visitor; it reads only the requester's own list, by the signed-in customer or the guest cookie. Selected by none — the list is the requester's own; get[product_id] on a product page only leaves that product out |
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 — 23 distinct bounds, each named by the triple it scopes: bounded by core's store-scoped getProduct(), and the caller's own customer id bounded by core's store-scoped getProduct(), and the guest token minted into the wishlist_guest cookie on this first save bounded by nothing is read from the database and nothing is written bounded by nothing per-caller — an enabled credential reads every store's watches, because the API is the merchant's own view of their shop bounded by nothing per-caller; the resource is an aggregate over every saved row and names no shopper bounded by nothing — a guest has no account to mail and no row here bounded by oc_customer_wishlist.customer_id and config_store_id bounded by the caller's own customer id and config_store_id, both in the DELETE's own WHERE bounded by the caller's own customer id or guest token, which is what the share row is keyed on bounded by the customer_id and store_id inside the code, which the HMAC is over bounded by the digest the sweep built, which is already one customer's bounded by the framework, exactly as on the sweep above bounded by the framework: the front controller spreads an empty $args on every release this extension names, so a query string cannot become a method argument bounded by the guest token off the wishlist_guest cookie and config_store_id bounded by the one secret this installation stores, minted at install and replaced only by Rotate bounded by the operating-system account the file is run as bounded by the same one secret the sweep is opened with — one installation, one value, both rows bounded by the session's own customer id or guest cookie, and config_store_id bounded by the signed-in customer's id, or the guest cookie's token checked by Guest::valid(), and config_store_id bounded by wishlist_alert.customer_id and store_id bounded by wishlist_guest_item.guest_token and config_store_id bounded by wishlist_share.share_token and the row's own open state bounded by wishlist_share.share_token, a 32-character oc_token, and the row's own open state |
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 — 2 self-contained surfaces of 6 bearer-secret surfaces:admin/controller/module/wishlist.php:229 — yes — one secret per installation is the whole authority for the two URL doors; it names no record and there is no second factor. The other caller admitted at those routes proves itself with the $cron_id argument core's scheduler passes and carries no secret at all, and the command line door proves itself with the server APIadmin/model/module/wishlist.php:91 — yes — the code carries its own claim: customer_id and store_id are in the string, and the HMAC beside them is what makes the pair trustworthy without a row to look up |
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 — 96 .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 — 6 bearer-secret surfaces, from core, minted — never from anything inside the secret presented:admin/controller/module/wishlist.php:229 — mintedcatalog/model/account/wishlist.php:616 — minted — oc_token(Guest::LENGTH) at catalog/model/account/wishlist.php:616, which is 32 hex characters and so 128 bits, minted on a guest's first savecatalog/model/account/share.php:133 — minted — oc_token(32) at catalog/model/account/share.php:133 and :165, which is 128 bits, rotated whenever the shopper asks for a new linkadmin/model/module/wishlist.php:91 — minted — the code is customer_id.store_id.HMAC, the HMAC being the first 32 hex characters of hash_hmac('sha256', …) over a per-store secret that is bin2hex(random_bytes(32)) at admin/model/module/wishlist.php:91system/library/api_gateway.php:948 — core — the key is whatever the merchant saved in core's own System → Users → API screen; this extension never mints it and never prints itcatalog/controller/account/wishlist.php:702 — 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 — 2 surfaces of 6 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:admin/controller/module/wishlist.php:229 — yes — one secret per installation is the whole authority for the two URL doors; it names no record and there is no second factor. The other caller admitted at those routes proves itself with the $cron_id argument core's scheduler passes and carries no secret at all, and the command line door proves itself with the server APIadmin/model/module/wishlist.php:91 — yes — the code carries its own claim: customer_id and store_id are in the string, and the HMAC beside them is what makes the pair trustworthy without a row to look up |
11.3.1 |
Nothing encrypts with a broken mode or padding — no ECB, no PKCS#1 v1.5. | checked — 96 .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 — 96 .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 — 1 hash use over 1 call to 1 hash function:hash_hmac('sha256'): the unsubscribe code's signature — system/library/unsubscribe.php:190, truncated to the first 32 hex characters — 128 bits — over the customer id and store id, keyed on a per-store secret. Verified with hash_equals() and never with ===. |
12.1.1 |
No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. | checked — 96 .php files |
12.2.1 |
An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. | checked — 96 .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 — 96 .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 — 4 bearer-secret surfaces of 6 travel in a URL:admin/controller/module/wishlist.php:229 — whatever scheme the store's catalog is served on. The secret travels in a query string a merchant pastes into a host's cron panel or a cron service, so it also sits in that panel, in the admin screen that prints both addresses (admin/controller/module/wishlist.php:229 and :230, each guarded by $secret !== '' so a store with none prints no address at all), in the JSON that rotate answers with, and in the web server's access logcatalog/model/account/share.php:133 — the URL query string of a link the shopper copies out of their own wishlist page, over whatever scheme the store's own site_url sets. The token is also rendered back into the shared page's add-all form as bulk_tokenadmin/model/module/wishlist.php:91 — the URL query string of a link printed in the alert mail, over the store_url the mail was built againstcatalog/controller/account/wishlist.php:702 — the URL query string of the account pages. The comparison is core's own expression from catalog/controller/account/login.php:298, transcribed including its loose ==, and it is not a constant-time compare |
14.3.1 |
Nothing is left behind in the browser's own storage for the next person at that computer to read. | checked — 19 .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 — 96 .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 — 70 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:module_wishlist_price_alert — settingmodule_wishlist_stock_alert — settingmodule_wishlist_alert_limit — settingmodule_wishlist_guest_retention — settingmodule_wishlist_module_limit — setting, held to 1..12 by Configuration::sanitise()report_wishlist_status — settingreport_wishlist_sort_order — settingversion_warning — the store's OpenCart release, through Compatibilitycron_scheduler_dead — the store's OpenCart release, through Compatibility — whether this release's own scheduler can run at allsweep_url — setting — the stored scheduled-route secret, in a URL built from HTTP_CATALOG and the hourly route; empty on a store that has no secretpurge_url — the same secret in the same shape, against the nightly routetext_door_url — both of those URLs, inside one language string; the placeholder wording where there is no secrettext_door_cli — DIR_OPENCART — the store's own directory, inside a language stringrotate — a link to this screen's own rotate route, carrying the admin session tokenversion_danger — the store's OpenCart release, where it is below the floor this extension installs oncopy_panel — rendered partial over the merchant's own storefront wording, per languagecopy — the merchant's stored wording overrides beside this extension's shipped defaultslanguage_readout — rendered partial over oc_languageresolution — rendered from oc_language, through LanguageReadoutlanguages — database — core's oc_language, unfilteredapi_panel — rendered partial over the store's own API stateapi — database and settings — the store's base URL, whether the API is on, the declared versions, and core's oc_api usernames with whether each has a user and an IP allowed. The credential list carries usernames and never a keystores — database — core's oc_store, with store 0 named from the store's own config_namestore — database — the store a row belongs to, and on the mail and the unsubscribe page the store's own nameitems — database — one customer's saved products, named and priced out of core's cataloguesummary — database — counts of that customer's saved products per storeadd — a link to this extension's own add route, carrying the admin session tokenremove — a link to this extension's own remove route, carrying the admin session tokenuser_token — the admin session token, rendered into the report screen's own JavaScript URLslist — a link to this extension's own list route, carrying the admin session token and the current filterwishlisted_by — the three audiences the report can be filtered to, named from the language filefilter_name — request — get[filter_name]filter_store_id — request — get[filter_store_id], cast to intfilter_date_from — request — get[filter_date_from]filter_date_to — request — get[filter_date_to]filter_wishlisted_by — request — get[filter_wishlisted_by], narrowed to the three audiencesfilter_out_of_stock — request — get[filter_out_of_stock], cast to intproducts — database — on the report, the demand ranking over oc_customer_wishlist and wishlist_guest_item joined to core's catalogue; on the share page, a row per saved product ending in a whole getProduct() row merged in at catalog/controller/account/share.php:152, so every oc_product and oc_product_description column is under itsort — request — get[sort], narrowed by Demand::sort() to the columns it names itselforder — request — get[order], narrowed to ASC or DESCsorts — the sort links for each column, carrying the admin session token and the current filterresults — a row count, into a language stringprice — database — a product's price, formatted in the shopper's own currency, or false where the store hides prices from signed-out visitorsstock — database — a product's stock status as core names ittitle — the shared list's heading, a language string carrying no name — a share link names nobodymissing — whether the token named no live share rowbulk — rendered partial holding the add-all controlbulk_action — a link to the cart route, carrying the signed-in shopper's own customer token where there is onebulk_cart — a link to core's own common/cart.infobulk_token — the share token itself, rendered into the shared page's own form so the add-all posts it back; empty on the shopper's own wishlist pagealert_price — database — whether this customer asked about price drops, per productalert_stock — database — whether this customer asked about restocks, per productalert_guest — whether the reader is a guest, who has no alerts to setalert_action — a link to this extension's own alert route, carrying the shopper's own customer tokenalert_products — database — the saved products the alert panel offers switches for, named out of core's catalogueshare_link — database — the shopper's own share URL, carrying their own 32-character share tokenshare_off — database — whether this shopper has a live share rowshare_action — a link to this extension's own share route, carrying the shopper's own customer tokendone — whether the unsubscribe code verified and the marker row was writtenwishlist — a link back to the storefront wishlist page, in the store's own languageall — a link to the storefront wishlist page, carrying the signed-in shopper's own customer token where there is onehome — a link back to the storefront home pagestore_url — setting — the store's own URL, concatenated rather than built with Url::link() because the sweep runs at store 0logo — setting — the store's own logo, as an absolute URLobserved — database — the products that moved, with what they moved from and tomanage — a link to the shopper's own wishlist pageunsubscribe — the signed opt-out URL for this customer and store, empty where the store has no alert secrettext_greeting — the merchant's own wording, with the customer's first name in ittext_unsubscribe — the merchant's own wording for the opt-out linetext_wishlist — the merchant's own wording for the wishlist link, and the header counter's label |
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 — 96 .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 — 96 .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 — 96 .php files |