Skip to content

Security verdict

20 checked, 5 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. declared — 3 bearer-secret surfaces over 11 mint, compare and refuse sites (6 × mint, 2 × constant-time compare, 0 × compare, 3 × refusal); 2 further entries say what the derivation reached that is not a secret:
the secret query parameter on extension/back_in_stock/cron/back_in_stock — minted at system/library/secret.php:47 as bin2hex(random_bytes(32)), 64 hexadecimal characters and 256 bits; compared at system/library/sweep_access.php:73 with hash_equals, and refused at sweep_access.php:63 when the stored secret is empty or the parameter is not a string, which is what ?secret[]= arrives as. Compared in constant time at system/library/sweep_access.php:73. Refused when unset at system/library/sweep_access.php:63. Minted with 256 bits of CSPRNG output at system/library/secret.php:47, which meets the 128-bit floor.
the code query parameter on extension/back_in_stock/notify.confirm and notify.unsubscribe — minted at system/library/secret.php:69 as bin2hex(random_bytes(16)), 32 hexadecimal characters and 128 bits, sized to what core sizes oc_gdpr.code to; looked up at system/library/port/database_subscriptions.php:392 and refused as blank at :387 and :552 before any lookup. No comparison of it happens in this extension's own code. Refused when unset at system/library/port/database_subscriptions.php:387, system/library/port/database_subscriptions.php:552. Minted with 128 bits of CSPRNG output at system/library/secret.php:69, which meets the 128-bit floor.
the HTTP Basic key on the two 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.
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: 45 store-derived subtrees over 11 templates; unverified beyond it: everything else:
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/back_in_stock.twig.
can_modify — session — whether the signed-in admin's group has modify on the waiting-list route. Rendered by admin/view/template/catalog/back_in_stock_list.twig, admin/view/template/catalog/back_in_stock_lookup.twig.
captcha — core — whatever the store's chosen captcha extension rendered. Rendered by catalog/view/template/product/capture.twig.
captcha_warning — settings — whether the store has a captcha the capture form can use. Rendered by admin/view/template/module/back_in_stock.twig.
copy_panel — settings and database — the merchant's own wording, per language. Rendered by admin/view/template/module/back_in_stock.twig, admin/view/template/module/copy_panel.twig.
counters — database — how many are waiting per product and option value, with the product names. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/catalog/back_in_stock_tab.twig.
fallback — database — the option values of this product that are out of stock, by name. Rendered by catalog/view/template/product/capture.twig.
filter_counter — request — which counter the waiting list is filtered to. Rendered by admin/view/template/catalog/back_in_stock.twig.
filter_email — request — the address the merchant looked up. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/catalog/back_in_stock_lookup.twig.
filter_name — request — the product name the report is filtered to. Rendered by admin/view/template/report/back_in_stock.twig.
filter_out_of_stock — request — whether the report is narrowed to what is out of stock. Rendered by admin/view/template/report/back_in_stock.twig.
filter_state — request — which state the waiting list is filtered to. Rendered by admin/view/template/catalog/back_in_stock.twig.
filter_store_id — request — which store the report is filtered to. Rendered by admin/view/template/report/back_in_stock.twig.
filter_waiting_longer — request — the wait bucket the report is filtered to. Rendered by admin/view/template/report/back_in_stock.twig.
heading_capture — settings — the merchant's own heading for this language, ours where they wrote none. Rendered by catalog/view/template/product/capture.twig.
install_warning — database — whether the catalog and report screens are installed and permissioned. Rendered by admin/view/template/module/back_in_stock.twig.
language_readout — database — the store's installed languages against what this extension ships. Rendered by admin/view/template/module/back_in_stock.twig, admin/view/template/module/language_readout.twig.
last_swept — settings — when the sweep last ran, formatted in the admin language. Rendered by admin/view/template/catalog/back_in_stock.twig.
list — a rendered sub-template of this extension's own, carrying the rows and their pagination. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/report/back_in_stock.twig.
lookup — a rendered sub-template of this extension's own, carrying one address's watches. Rendered by admin/view/template/catalog/back_in_stock.twig.
neighbour_status — database — whether the neighbourhood tables this extension reads are present. Rendered by admin/view/template/module/back_in_stock.twig.
notice — database — the stored consent wording, with this product and option value named in it. Rendered by catalog/view/template/product/capture.twig.
notice_preview — settings — what the consent sentence currently reads as. Rendered by admin/view/template/module/back_in_stock.twig.
order — request — the direction the report is sorted in, narrowed to ASC or DESC. Rendered by admin/view/template/report/back_in_stock_list.twig.
product_id — core — the product the capture block is on. Rendered by catalog/view/template/product/capture.twig.
products — database — product names with how many are waiting and for how long. Rendered by admin/view/template/report/back_in_stock_list.twig.
searched — request — whether a lookup was actually asked for. Rendered by admin/view/template/catalog/back_in_stock_lookup.twig.
secret_warning — settings — whether a sweep secret exists at all. Rendered by admin/view/template/module/back_in_stock.twig.
sending_warning — settings and database — whether anything is standing between a confirmed watch and a mail. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/module/back_in_stock.twig.
sort — request — the column the report is sorted on, narrowed to a key of Demand::SORTS. Rendered by admin/view/template/report/back_in_stock_list.twig.
state — database — this product's option-value availability, JSON-encoded and htmlspecialchars'd, with the product name in it. Rendered by catalog/view/template/product/capture.twig.
states — database — a count per subscription state for this product. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/catalog/back_in_stock_tab.twig.
stores — database and settings — the store names, config_name standing in for store 0. Rendered by admin/view/template/report/back_in_stock.twig.
strip — database — the counts along the top of the screen. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/report/back_in_stock.twig.
subscriptions — database — the watch rows, which are customer email addresses with their product, state and stamps. Rendered by admin/view/template/catalog/back_in_stock_list.twig, admin/view/template/catalog/back_in_stock_lookup.twig.
suppression — database — whether this address is suppressed, and since when. Rendered by admin/view/template/catalog/back_in_stock_lookup.twig.
sweep_url — settings — the sweep address, with the store-wide sweep secret in the query string. Rendered by admin/view/template/module/back_in_stock.twig.
sweep_warning — settings — whether the sweep has run recently enough to be believed. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/module/back_in_stock.twig.
text_door_cli — runtime — the shipped sentence with this store's own DIR_EXTENSION path in it. Rendered by admin/view/template/module/back_in_stock.twig.
text_door_cron — runtime — the shipped sentence with this store's own DIR_OPENCART path in it. Rendered by admin/view/template/module/back_in_stock.twig.
text_door_url — settings — the shipped sentence with the sweep address in it, and therefore the sweep secret. Rendered by admin/view/template/module/back_in_stock.twig.
user_token — session — core's own admin session token. Rendered by admin/view/template/catalog/back_in_stock.twig, admin/view/template/report/back_in_stock.twig.
version_danger — runtime — the store's own OpenCart VERSION, against this extension's floor. Rendered by admin/view/template/module/back_in_stock.twig.
version_warning — runtime — the store's own OpenCart VERSION, against what this extension is tested on. Rendered by admin/view/template/module/back_in_stock.twig.
wording_hash — database — the sha256 of the consent body this form is offering. Rendered by catalog/view/template/product/capture.twig.
Unverified beyond it: the other 526 of 568 template expressions in 18 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. not met — 42 url attributes carrying a template expression, of 42 sink sites asserted:
extensions/back_in_stock/src/admin/view/template/catalog/back_in_stock_list.twig:22 — href="{{ notice }}&hash={{ subscription.wording_hash }}" composes a URL in the template rather than taking one whole from the link helper, and not every value in it is URL-encoded
extensions/back_in_stock/src/admin/view/template/catalog/back_in_stock_lookup.twig:29 — href="{{ notice }}&hash={{ subscription.wording_hash }}" composes a URL in the template rather than taking one whole from the link helper, and not every value in it is URL-encoded
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 — 18 .twig files:
extensions/back_in_stock/src/admin/view/template/catalog/back_in_stock.twig:110 — {{ user_token }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/catalog/back_in_stock.twig:122 — {{ user_token }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/module/back_in_stock.twig:331 — {{ rotate }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/module/back_in_stock.twig:355 — {{ sample.test }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/report/back_in_stock.twig:122 — {{ user_token }} is interpolated inside a <script> element
extensions/back_in_stock/src/admin/view/template/report/back_in_stock.twig:136 — {{ 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 — 5 ways of building a statement, over 84 statements run and 47 values escaped:
a value interpolated into a statement as an escaped string literal — $this->db->escape($value) inside single quotes, which is core's own idiom. Twenty-seven sites in system/library/port/database_subscriptions.php, six in admin/model/catalog/back_in_stock.php, five in catalog/model/api/back_in_stock.php, four in admin/model/module/back_in_stock.php, three in admin/model/report/back_in_stock.php, and one each in the settings mirror and the API event handler.
a value interpolated into a statement as a bare integer — A PHP (int) cast concatenated without quotes — used for subscription ids, product ids, option value ids and store ids throughout the models and the subscriptions port.
a table name interpolated as an identifier — DB_PREFIX, or the prefix DatabaseSubscriptions was constructed with, concatenated between backticks. It comes from the store's config.php and never from a request.
a column name interpolated as an identifier, from a fixed list — admin/model/report/back_in_stock.php:145 concatenates $sort['expression'] into an ORDER BY between backticks. The value is Demand::sort()'s own, which falls back to SORT_WAITING for anything that is not a key of Demand::SORTS, and the direction is narrowed to ASC or DESC — so what reaches the statement is one of four expressions this extension wrote, never what arrived in the URL.
a whole clause concatenated onto a statement — $where .= in system/library/port/database_subscriptions.php, and an IN (...) list built by implode() over ids and state constants — database_subscriptions.php:93,140, admin/model/catalog/back_in_stock.php:48,357, admin/model/report/back_in_stock.php:235.
19 of the 84 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 — 100 .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 — 18 .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 — 100 .php files:
extensions/back_in_stock/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 — 100 .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 — 18 of 41 routes set a Content-Type of their own:
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="back-in-stock-demand-.csv" and Cache-Control: no-store
10 × application/json
1 × application/json where the request carried X-Requested-With; otherwise none — a redirect back to the product page carrying a flag rather than a message, so a hand-written query string cannot put words on somebody's product page
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, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
4 × none
1 × none set, and no output written
1 × none set; the page goes out under the front controller's default with Content-Security-Policy: sandbox beside it — the email's HTML part as the sweep renders it, its text part escaped into a <pre>, in an opaque origin that runs no script
1 × none — a partial rendered into the dashboard or the report page
5 × none — a partial rendered into the page that asked for it
2 × none — nothing sets a Content-Type, so the store's default stands
2 × none — the 404 body is plain text with no type set
4 × none — the page goes out as core renders one
3 × none — the screen goes out as core renders a 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 — 14 call sites in 3 templates, each declared with what it writes there:
2 × .append(
12 × .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 — 100 .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 — 100 .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 — 9 routes of 41 reaches a model write; the 9 admin ones among them stand behind the user_token core checks before dispatch, and 31 admin routes are gated that way in all:
No storefront route of this extension reaches a model write.
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 — 100 .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 — 100 .php files:
extensions/back_in_stock/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 does
extensions/back_in_stock/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 does
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:235 — BackInStock::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:379 — BackInStock::rotate() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:556 — BackInStock::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:721 — BackInStock::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:107 — BackInStock::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:155 — BackInStock::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:189 — BackInStock::uninstall() 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 — 18 of 41 routes set a Content-Type of their own:
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="back-in-stock-demand-.csv" and Cache-Control: no-store
10 × application/json
1 × application/json where the request carried X-Requested-With; otherwise none — a redirect back to the product page carrying a flag rather than a message, so a hand-written query string cannot put words on somebody's product page
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, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
4 × none
1 × none set, and no output written
1 × none set; the page goes out under the front controller's default with Content-Security-Policy: sandbox beside it — the email's HTML part as the sweep renders it, its text part escaped into a <pre>, in an opaque origin that runs no script
1 × none — a partial rendered into the dashboard or the report page
5 × none — a partial rendered into the page that asked for it
2 × none — nothing sets a Content-Type, so the store's default stands
2 × none — the 404 body is plain text with no type set
4 × none — the page goes out as core renders one
3 × none — the screen goes out as core renders a 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 41 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 41 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 — 13 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:437
system/library/diary.php:470 — the same kyvero.log, opened r+ to trim it back under the 1 MiB cap, at system/library/diary.php:470
system/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 — 13 write sites, each declared with its file:line and pinned against the token stream both ways:
3 × log file
10 × 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 — 18 .twig files
6.2.7 A masked field does not refuse a paste or shut a password manager out of it. checked — 18 .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 — 100 .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 — 41 routes: 31 admin, 10 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 — 31 admin routes: 19 pin a permission themselves, 0 at one same-class hop, 0 at two (the hop ceiling), 12 unpinned:
extensions/back_in_stock/src/admin/controller/catalog/back_in_stock.php:71 — BackInStock::index() pins no permission of its own; core checks access on extension/back_in_stock/catalog/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/catalog/back_in_stock.php:150 — BackInStock::list() pins no permission of its own; core checks access on extension/back_in_stock/catalog/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/catalog/back_in_stock.php:164 — BackInStock::lookup() pins no permission of its own; core checks access on extension/back_in_stock/catalog/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/catalog/back_in_stock.php:181 — BackInStock::notice() pins no permission of its own; core checks access on extension/back_in_stock/catalog/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/back_in_stock/customer/purge before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:123 — BackInStock::index() pins no permission of its own; core checks access on extension/back_in_stock/module/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/module/back_in_stock.php:428 — BackInStock::preview() pins no permission of its own; core checks access on extension/back_in_stock/module/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:67 — BackInStock::index() pins no permission of its own; core checks access on extension/back_in_stock/report/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:207 — BackInStock::report() pins no permission of its own; core checks access on extension/back_in_stock/report/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:239 — BackInStock::list() pins no permission of its own; core checks access on extension/back_in_stock/report/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:257 — BackInStock::export() pins no permission of its own; core checks access on extension/back_in_stock/report/back_in_stock before dispatch. No model write is reachable from it.
extensions/back_in_stock/src/admin/controller/report/back_in_stock.php:310 — BackInStock::getReport() pins no permission of its own; core checks access on extension/back_in_stock/report/back_in_stock 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 — 12 triples over 9 of 10 catalog routes; the admin half is one line on the shared page:
extension/back_in_stock/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 nothing
extension/back_in_stock/api/v1/watch — a holder of one of core's oc_api credentials: The credential cannot be scoped to a store, a product or a shopper. The contact address is returned in full because it is the only identity a watch has — most shoppers here have no account at all — so redacting it would remove no access from anybody. Selected by watch_id
extension/back_in_stock/api/v1/watch — a holder of one of core's oc_api credentials, walking the collection: filter_opencart_customer_id and the rest are conveniences for a caller who wants a subset; the same credential may simply leave them off. Selected by none — not a record; the filters and the cursor are a page of a full walk rather than a record selector
extension/back_in_stock/cli/back_in_stock — whoever can run PHP on the server: Reached through extension/back_in_stock/back_in_stock.php, and refused outright to anything that is not a terminal. Selected by none — not a record; the arguments come from argv, which is not a request key
extension/back_in_stock/cron/back_in_stock — OpenCart's own scheduler: Core spreads the cron row into the controller arguments and the front controller passes an empty array, so a positive $cron_id is the proof, supplied by the framework rather than by a convention of ours. Selected by none — not a record; the route reads no record key and reconciles every watch due
extension/back_in_stock/cron/back_in_stock — anybody holding the store-wide sweep secret: This is the door a store on 4.1.0.4 has, where cron.php dies inside core. Holding the secret runs the pass a scheduler would have run an hour later and no more: the claim is a conditional UPDATE mailing on one affected row, and the per-pass cap bounds what one call can do. Selected by none — not a record; secret is the whole credential and names nothing
extension/back_in_stock/notify.confirm — the mailbox a confirmation link was sent to: Holding the secret already means holding the authority to confirm or withdraw that one row. A token naming nothing gets the same plain page an already-used link gets, and reveals nothing about whether the address exists. Selected by code
extension/back_in_stock/notify.unsubscribe — the mailbox an alert was sent to: One unauthenticated GET completes the unsubscribe, which is what the token is for: a mail client that prefetches the link does what the recipient was going to do anyway. A second click is the ordinary way to reach the unknown-token page, because the first one deleted the row. Selected by code
extension/back_in_stock/notify.stopAll — the mailbox whose token opened this session: The row the token named no longer exists by the time this page renders, so the session is what carries the address — and requiring a POST is what stops a mail client or a security scanner suppressing on the recipient behalf before they had read the page. Selected by none — not a record; nothing in the request names what is acted on
extension/back_in_stock/notify.erase — the mailbox whose token opened this session: Erasure here is deletion rather than blanking, so an erased watch is simply gone — and the page says which of the two things happened to the suppression entry, because "we deleted everything" and "we kept the one record that stops us mailing you" are different promises. Selected by none — not a record; nothing in the request names what is erased
extension/back_in_stock/product.notify — any visitor to the storefront, signed in or not: The response never varies with the address: suppression, the cooldown and the honeypot all answer the same neutral sentence, because a form that answered differently would be an oracle for whether a given address is on the store's list. Selected by product_id and product_option_value_id
extension/back_in_stock/product.notify — a signed-in customer: The account address is taken alongside the typed one rather than instead of it, and neither is echoed back — a visitor cannot learn from this route whose account is signed in. Selected by none — not a record; customer_id and the account address are read from the session, never from the request
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 — 11 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 about the visitor — anybody may ask to be told about any product; what bounds it is the confirmation cooldown, the suppression list and the honeypot, none of which change the answer
bounded by nothing — a filter is the caller narrowing their own read, never the store narrowing what they may see
bounded by nothing — one secret per installation opens the sweep over every store
bounded by nothing — the refusal is the same for every caller
bounded by the $cron_id argument core's dispatcher supplies, which a query string cannot carry
bounded by the address in the session, written only by an arrival carrying a token that named a real row
bounded by the one subscription row the token names
bounded by the one subscription row the token names, and its state — a row that is no longer pending confirms nothing
bounded by the operating-system account the file is run as
bounded 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 — 2 self-contained surfaces of 3 bearer-secret surfaces:
system/library/secret.php:47 — yes — one secret per installation is the whole authority for the URL door; it names no record and there is no second factor. The other door onto the same route proves itself with the $cron_id argument instead and carries no secret at all
system/library/port/database_subscriptions.php:387 — yes — holding it is holding the authority to confirm or to withdraw that one watch, with no login, no session and no confirmation interstitial
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 — 100 .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 — 3 bearer-secret surfaces, from core, minted — never from anything inside the secret presented:
system/library/secret.php:47 — minted
system/library/port/database_subscriptions.php:387 — minted
system/library/api_gateway.php:948 — core
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 3 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:
system/library/secret.php:47 — yes — one secret per installation is the whole authority for the URL door; it names no record and there is no second factor. The other door onto the same route proves itself with the $cron_id argument instead and carries no secret at all
system/library/port/database_subscriptions.php:387 — yes — holding it is holding the authority to confirm or to withdraw that one watch, with no login, no session and no confirmation interstitial
11.3.1 Nothing encrypts with a broken mode or padding — no ECB, no PKCS#1 v1.5. checked — 100 .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 — 100 .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:
the fingerprint of a consent sentence — system/library/notice.php:176 — hash('sha256', $body), 64 hexadecimal characters, which is what the column holds. It identifies which wording a shopper actually agreed to, so the admin waiting list can read the body back and show it. Not a credential: it is stored beside the row it describes, it is emitted in the capture form as wording_hash, and nothing is authorised by holding it. The method named hash on that same class is a method, not a call to PHP's.
12.1.1 No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. checked — 100 .php files
12.2.1 An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. checked — 100 .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 — 100 .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 — 2 bearer-secret surfaces of 3 travel in a URL:
system/library/secret.php:47 — whatever scheme the store's catalog is served on. The secret travels in a query string a merchant pastes into a crontab line or a cron service, so it also sits in that crontab, in the admin screen that prints it, and in the web server's access log
system/library/port/database_subscriptions.php:387 — whatever scheme the store's catalog is served on. It travels in the query string of an emailed link, so it is in the mailbox and in the browser history; the API never emits it
14.3.1 Nothing is left behind in the browser's own storage for the next person at that computer to read. checked — 18 .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 — 100 .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 — 45 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:
api_panel — settings and database — whether the API is on, and core's own oc_api rows
can_modify — session — whether the signed-in admin's group has modify on the waiting-list route
captcha — core — whatever the store's chosen captcha extension rendered
captcha_warning — settings — whether the store has a captcha the capture form can use
copy_panel — settings and database — the merchant's own wording, per language
counters — database — how many are waiting per product and option value, with the product names
fallback — database — the option values of this product that are out of stock, by name
filter_counter — request — which counter the waiting list is filtered to
filter_email — request — the address the merchant looked up
filter_name — request — the product name the report is filtered to
filter_out_of_stock — request — whether the report is narrowed to what is out of stock
filter_state — request — which state the waiting list is filtered to
filter_store_id — request — which store the report is filtered to
filter_waiting_longer — request — the wait bucket the report is filtered to
heading_capture — settings — the merchant's own heading for this language, ours where they wrote none
install_warning — database — whether the catalog and report screens are installed and permissioned
language_readout — database — the store's installed languages against what this extension ships
last_swept — settings — when the sweep last ran, formatted in the admin language
list — a rendered sub-template of this extension's own, carrying the rows and their pagination
lookup — a rendered sub-template of this extension's own, carrying one address's watches
neighbour_status — database — whether the neighbourhood tables this extension reads are present
notice — database — the stored consent wording, with this product and option value named in it
notice_preview — settings — what the consent sentence currently reads as
order — request — the direction the report is sorted in, narrowed to ASC or DESC
product_id — core — the product the capture block is on
products — database — product names with how many are waiting and for how long
searched — request — whether a lookup was actually asked for
secret_warning — settings — whether a sweep secret exists at all
sending_warning — settings and database — whether anything is standing between a confirmed watch and a mail
sort — request — the column the report is sorted on, narrowed to a key of Demand::SORTS
state — database — this product's option-value availability, JSON-encoded and htmlspecialchars'd, with the product name in it
states — database — a count per subscription state for this product
stores — database and settings — the store names, config_name standing in for store 0
strip — database — the counts along the top of the screen
subscriptions — database — the watch rows, which are customer email addresses with their product, state and stamps
suppression — database — whether this address is suppressed, and since when
sweep_url — settings — the sweep address, with the store-wide sweep secret in the query string
sweep_warning — settings — whether the sweep has run recently enough to be believed
text_door_cli — runtime — the shipped sentence with this store's own DIR_EXTENSION path in it
text_door_cron — runtime — the shipped sentence with this store's own DIR_OPENCART path in it
text_door_url — settings — the shipped sentence with the sweep address in it, and therefore the sweep secret
user_token — session — core's own admin session token
version_danger — runtime — the store's own OpenCart VERSION, against this extension's floor
version_warning — runtime — the store's own OpenCart VERSION, against what this extension is tested on
wording_hash — database — the sha256 of the consent body this form is offering
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 — 100 .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 — 100 .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 — 100 .php files