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 7 mint, compare and refuse sites (4 × mint, 2 × constant-time compare, 0 × compare, 1 × refusal); 1 further entry says what the derivation reached that is not a secret:
the token on extension/product_feed/feed/product_feed — minted at system/library/secret.php:40 as bin2hex(random_bytes(32)), 64 hexadecimal characters and 256 bits; compared at system/library/feed_access.php:73 with hash_equals, and refused at feed_access.php:63 when the stored secret is empty. Compared in constant time at system/library/feed_access.php:73. Refused when unset at system/library/feed_access.php:63. Minted with 256 bits of CSPRNG output at system/library/secret.php:40, which meets the 128-bit floor.
the HTTP Basic key on the api/ routes — core's own oc_api credential, read at system/library/api_gateway.php:759-790 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:857 with hash_equals against a dummy of equal length where the username is unknown. No comparison of it happens in this extension's own code. 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 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.
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: 4 sink sites asserted here, 1 not admitted; attested over the inventory: 40 store-derived subtrees over 7 templates; unverified beyond it: everything else:
extensions/product_feed/src/admin/view/template/module/product_feed.twig:132 — style="width: {{ feed.percent }}%" puts a value in a CSS context, which nothing in Twig escapes for
categories — database — category names and their accepted taxonomy ids. Rendered by admin/view/template/module/product_feed_categories.twig.
categories_unsettled — database — a count of categories with no mapping. Rendered by admin/view/template/module/product_feed_form.twig.
channel — database — the feed row's channel code, or the request where a feed is being created. Rendered by admin/view/template/module/product_feed_form.twig.
chosen — request and database — whether this form is editing an existing feed. Rendered by admin/view/template/module/product_feed_form.twig.
crumbs — request — the taxonomy path the picker is at, split into segments. Rendered by admin/view/template/module/product_feed_taxonomy.twig.
currencies — database — core's localisation currencies. Rendered by admin/view/template/module/product_feed_form.twig.
currency — database and settings — the feed row's currency, or config_currency. Rendered by admin/view/template/module/product_feed_form.twig.
cycle — database — the feed row's schedule cycle. Rendered by admin/view/template/module/product_feed_form.twig.
error — runtime — a language string, or the message of an exception the preview run raised. Rendered by admin/view/template/module/product_feed_preview.twig.
feed_id — request — the feed the form is editing. Rendered by admin/view/template/module/product_feed_form.twig.
feed_name — database — the feed row's name, as the merchant typed it. Rendered by admin/view/template/module/product_feed_preview.twig.
feeds — database — every feed row, with its channel, status and last run. Rendered by admin/view/template/module/product_feed.twig.
fields — database — the merchant's field mapping over the channel's declared fields. Rendered by admin/view/template/module/product_feed_preview.twig.
fields_missing — database — a count of required fields the mapping has no source for. Rendered by admin/view/template/module/product_feed_form.twig.
filter_categories — database — category names, with the feed's own selection and exclusion marked. Rendered by admin/view/template/module/product_feed_form.twig.
filter_exclude_products — database — the names of the products the feed leaves out, in the admin's language. Rendered by admin/view/template/module/product_feed_form.twig.
filter_manufacturers — database — manufacturer names, with the feed's own selection and exclusion marked. Rendered by admin/view/template/module/product_feed_form.twig.
filter_name — request — what the merchant typed into the picker search. Rendered by admin/view/template/module/product_feed_categories.twig, admin/view/template/module/product_feed_taxonomy.twig.
filter_stock_statuses — database — stock status names, with the feed's own selection marked. Rendered by admin/view/template/module/product_feed_form.twig.
language_id — database, request and settings — whichever of the three named the language being worked in. Rendered by admin/view/template/module/product_feed_categories.twig, admin/view/template/module/product_feed_form.twig, admin/view/template/module/product_feed_review.twig.
language_readout — database — the store's installed languages against what this extension ships. Rendered by admin/view/template/module/language_readout.twig, admin/view/template/module/product_feed.twig.
languages — database — core's installed languages. Rendered by admin/view/template/module/product_feed_form.twig.
locale — shipped data file — the taxonomy locale that resolved for this language. Rendered by admin/view/template/module/product_feed_categories.twig, admin/view/template/module/product_feed_taxonomy.twig.
name — database and request — the feed's name, as the merchant typed it. Rendered by admin/view/template/module/product_feed_form.twig.
nodes — shipped data file — the taxonomy children below the path being browsed. Rendered by admin/view/template/module/product_feed_taxonomy.twig.
page_suggestions — database — how many of this page's categories carry a proposal. Rendered by admin/view/template/module/product_feed_categories.twig.
pending — database — a count of unsettled proposals. Rendered by admin/view/template/module/product_feed_categories.twig.
refused — runtime — how many rows the preview run rejected. Rendered by admin/view/template/module/product_feed_preview.twig.
results — database and shipped data file — what the picker search matched. Rendered by admin/view/template/module/product_feed_categories.twig, admin/view/template/module/product_feed_taxonomy.twig.
rows — database — real product rows as the generator would write them, cell by cell. Rendered by admin/view/template/module/product_feed_preview.twig.
schedule_status — database — whether the feed row's schedule is on. Rendered by admin/view/template/module/product_feed_form.twig.
status — database — whether the feed row is on. Rendered by admin/view/template/module/product_feed_form.twig.
store_id — database and request — which store is being worked in. Rendered by admin/view/template/module/product_feed_categories.twig, admin/view/template/module/product_feed_form.twig, admin/view/template/module/product_feed_review.twig.
stores — database and settings — the store names, config_name standing in for store 0. Rendered by admin/view/template/module/product_feed_form.twig.
suggestions — database — the stored proposals, with the category and taxonomy names they name. Rendered by admin/view/template/module/product_feed_review.twig.
unsettled — database — a count of categories with no answer yet. Rendered by admin/view/template/module/product_feed_categories.twig.
url — database — the hosted feed address, with that feed's secret in the query string. Rendered by admin/view/template/module/product_feed_form.twig.
user_token — session — core's own admin session token. Rendered by admin/view/template/module/product_feed.twig, admin/view/template/module/product_feed_form.twig.
version — shipped data file — the taxonomy version in use. Rendered by admin/view/template/module/product_feed_taxonomy.twig.
version_warning — runtime — the store's own OpenCart VERSION, against what this extension is tested on. Rendered by admin/view/template/module/product_feed.twig, admin/view/template/module/product_feed_form.twig.
Unverified beyond it: the other 515 of 549 template expressions in 12 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 — 30 url attributes carrying a template expression, of 34 sink sites asserted:
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:218 — href="#field-{{ field.code }}" 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/product_feed/src/admin/view/template/module/product_feed_taxonomy.twig:38 — href="{{ browse }}&path={{ crumb.path|url_encode }}" 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/product_feed/src/admin/view/template/module/product_feed_taxonomy.twig:48 — href="{{ browse }}&path={{ node.path|url_encode }}" 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 — 12 .twig files:
extensions/product_feed/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed.twig:181 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed.twig:229 — {{ text_confirm_delete }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed.twig:235 — {{ text_confirm_rotate }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed.twig:252 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:458 — {{ text_state_mapped }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:458 — {{ text_state_fallback }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:458 — {{ text_state_missing }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:458 — {{ text_state_unset }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:480 — {{ text_confirm_rotate }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:487 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:514 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:514 — {{ feed_id }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:515 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:539 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:615 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:705 — {{ user_token }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:735 — {{ text_taxonomy_title }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:776 — {{ product_autocomplete }} is interpolated inside a <script> element
extensions/product_feed/src/admin/view/template/module/product_feed_form.twig:797 — {{ button_remove }} is interpolated inside a <script> element
1.2.4 Every way this extension builds a database statement is written down beside the code, so how a value reaches a query is a published answer rather than something to go looking for. declared — 4 ways of building a statement, over 81 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. Thirty-five sites in admin/model/module/product_feed.php, six in system/library/port/database_feeds.php, four in system/library/writer/xml.php and two in product_feed.php.
a value interpolated into a statement as a bare integer — A PHP (int) cast concatenated without quotes — used for ids and for store and language ids throughout admin/model/module/product_feed.php and system/library/port/database_feeds.php.
a table name interpolated as an identifier — DB_PREFIX, or the prefix DatabaseFeeds was constructed with, concatenated between backticks. It comes from the store's config.php and never from a request.
a whole clause concatenated onto a statement — $sql .= in admin/model/module/product_feed.php, adding a WHERE or an ORDER BY built from the screen's filters; the values inside each clause go in by one of the two mechanisms above.
14 of the 81 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 — 86 .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 — 12 .twig files
1.3.2 Nothing runs code it assembled while running — no eval(), and no include of a path a variable decided. checked — 86 .php files
1.5.1 Every XML parser is left at the restrictive default: nothing turns on external entity resolution, which is what would turn reading a spreadsheet into reading your server's files. checked — 86 .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 — 20 of 33 routes set a Content-Type of their own:
11 × application/json
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)
3 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
2 × none
1 × none set, and no output written
3 × 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
3 × none — the screen goes out as core renders a page
1 × the channel's own contentType(), through header(): 'application/xml' for Google and for Meta — with no charset — and 'text/csv; charset=utf-8' for Bing
1 × the channel's own contentType(), through header(): 'application/xml' for Google and for Meta — with no charset — and 'text/csv; charset=utf-8' for Bing; plus a Content-Disposition, which is what makes it a download
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 — 23 call sites in 4 templates, each declared with what it writes there:
5 × .append(
1 × .html(
17 × .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 — 86 .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 — 86 .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 — 12 routes of 33 reaches a model write; the 12 admin ones among them stand behind the user_token core checks before dispatch, and 26 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 — 86 .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 — 86 .php files:
extensions/product_feed/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/product_feed/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/product_feed/src/admin/controller/module/product_feed.php:642 — ProductFeed::saveCategory() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:735 — ProductFeed::suggestCategories() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:799 — ProductFeed::acceptCategory() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:884 — ProductFeed::rejectCategory() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1173 — ProductFeed::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1360 — ProductFeed::status() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1392 — ProductFeed::duplicate() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1429 — ProductFeed::delete() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1670 — ProductFeed::saveSettings() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_feed/src/admin/controller/module/product_feed.php:1758 — ProductFeed::install() 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 — 20 of 33 routes set a Content-Type of their own:
11 × application/json
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)
3 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
2 × none
1 × none set, and no output written
3 × 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
3 × none — the screen goes out as core renders a page
1 × the channel's own contentType(), through header(): 'application/xml' for Google and for Meta — with no charset — and 'text/csv; charset=utf-8' for Bing
1 × the channel's own contentType(), through header(): 'application/xml' for Google and for Meta — with no charset — and 'text/csv; charset=utf-8' for Bing; plus a Content-Disposition, which is what makes it a download
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 33 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 33 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 — 15 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 — 15 write sites, each declared with its file:line and pinned against the token stream both ways:
2 × directory
5 × feed file
3 × log file
5 × 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 — 12 .twig files
6.2.7 A masked field does not refuse a paste or shut a password manager out of it. checked — 12 .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 — 86 .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 — 33 routes: 26 admin, 7 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 — 26 admin routes: 16 pin a permission themselves, 3 at one same-class hop, 0 at two (the hop ceiling), 7 unpinned:
extensions/product_feed/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/product_feed/customer/purge before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:193 — ProductFeed::index() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:347 — ProductFeed::form() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:487 — ProductFeed::preview() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:620 — ProductFeed::categories() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:932 — ProductFeed::review() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
extensions/product_feed/src/admin/controller/module/product_feed.php:1095 — ProductFeed::taxonomy() pins no permission of its own; core checks access on extension/product_feed/module/product_feed before dispatch. No model write is reachable from it.
8.2.2 A storefront route that reaches a record says which caller may reach which records, and what selects one — so reaching somebody else's is a question with a written answer. declared — 9 triples over 6 of 7 catalog routes; the admin half is one line on the shared page:
extension/product_feed/cli/product_feed — whoever can run PHP on the server: Reached through extension/product_feed/product_feed.php, and refused outright to anything that is not a terminal. It partitions nothing per identity because there is one identity: the shell. Selected by none — not a record; --feed names a feed on the command line, which is not a request key
extension/product_feed/cron/product_feed — OpenCart's own scheduler, or a terminal on the store: There is no secret for this door and nothing to keep in sync: the proof is the framework passing an argument, so a caller over HTTP arrives with zero and is refused, and the second arm is the interface the caller arrived through rather than anything they sent. Selected by none — not a record; the route reads no request key at all and sweeps every feed due
extension/product_feed/feed/product_feed — a channel puller holding one feed's token: One feed's token serves that feed and no other: the row is looked up by feed_id first and its own secret is what the token is compared against, so a token that is another feed's matches nothing. Selected by feed_id
extension/product_feed/feed/product_feed — a channel puller holding one feed's token, on a multi-store install: A feed built for one shop is not a file the other hands out, even to a caller holding the right token — the address the request came in on decides which store is answering. Selected by none — not a record; the store is the host the request arrived on
extension/product_feed/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/product_feed/api/v1/feed — a holder of one of core's oc_api credentials: The credential cannot be scoped: there is no way to authorise a caller for one store or one feed, which is why the contract says so in a disclosure rather than leaving a reader to find out. The feed's own URL token is withheld under any name, so a credential that reads this resource still cannot fetch the document it describes. Selected by feed_id
extension/product_feed/api/v1/feed — a holder of one of core's oc_api credentials, walking the collection: filter_store_id is a convenience for a caller who wants one store, not a boundary: the same credential may simply leave it off. Selected by none — not a record; filter_store_id, filter_updated_at_from, filter_updated_at_to and the cursor are a page of a walk rather than a record selector
extension/product_feed/api/v1/run — a holder of one of core's oc_api credentials: The single fetch carries the run's rejections, which name products by OpenCart's own product_id and nothing else. The list is capped at the writer's 500 per run, so it is a sample of a bad night rather than the whole of one, and the run's own count is what reconciles against. Selected by run_id
extension/product_feed/api/v1/run — a holder of one of core's oc_api credentials, walking the collection: Rejections are on the single fetch and never on the collection, so a walk of a thousand runs drags no rejection rows behind it — a shape chosen for the caller, not a boundary on them. Selected by none — not a record; filter_feed_id, filter_state, filter_trigger, filter_created_at_from, filter_created_at_to and the cursor are a page of a walk rather than a record selector
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 — 7 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 config_store_id, compared against the feed row's store_id
bounded by nothing — a filter is the caller narrowing their own read, never the store narrowing what they may see
bounded by nothing — the refusal is the same for every caller
bounded by the $cron_id argument core's dispatcher supplies, which nothing reachable over HTTP can carry — or the cli SAPI, which no request can present either
bounded by the operating-system account the file is run as
bounded by the secret stored on the row feed_id names, compared with hash_equals, and the row's own store_id against config_store_id
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 — 1 self-contained surface of 3 bearer-secret surfaces:
system/library/feed_access.php:63 — yes — the token and the feed_id it names are the whole of what a caller presents; there is no session, no account and no second factor
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 — 86 .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/feed_access.php:63 — minted
system/library/api_gateway.php:759 — core
system/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 it
9.2.1 A secret that carries its own expiry is accepted only inside it, and the declaration says which ones carry one. declared — 1 surface 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/feed_access.php:63 — yes — the token and the feed_id it names are the whole of what a caller presents; there is no session, no account and no second factor
11.3.1 Nothing encrypts with a broken mode or padding — no ECB, no PKCS#1 v1.5. checked — 86 .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 — 86 .php files
11.4.1 Every hash this extension computes is written down with what it is for, so a hash naming a cache entry is not read as one standing in front of a secret. declared — 0 hash uses over 0 calls to 0 hash functions:
This extension computes no hash.
12.1.1 No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. checked — 86 .php files
12.2.1 An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. checked — 86 .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 — 86 .php files
14.2.1 A credential is not carried in a URL, where a browser history, a referrer header and a proxy log each keep their own copy of it. not met — 1 bearer-secret surface of 3 travels in a URL:
system/library/feed_access.php:63 — whatever scheme the store's catalog is served on, because the token travels in the query string a merchant pastes into a channel dashboard; the served response carries Cache-Control: private, no-cache so no shared cache keeps it
14.3.1 Nothing is left behind in the browser's own storage for the next person at that computer to read. checked — 12 .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 — 86 .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 — 40 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:
categories — database — category names and their accepted taxonomy ids
categories_unsettled — database — a count of categories with no mapping
channel — database — the feed row's channel code, or the request where a feed is being created
chosen — request and database — whether this form is editing an existing feed
crumbs — request — the taxonomy path the picker is at, split into segments
currencies — database — core's localisation currencies
currency — database and settings — the feed row's currency, or config_currency
cycle — database — the feed row's schedule cycle
error — runtime — a language string, or the message of an exception the preview run raised
feed_id — request — the feed the form is editing
feed_name — database — the feed row's name, as the merchant typed it
feeds — database — every feed row, with its channel, status and last run
fields — database — the merchant's field mapping over the channel's declared fields
fields_missing — database — a count of required fields the mapping has no source for
filter_categories — database — category names, with the feed's own selection and exclusion marked
filter_exclude_products — database — the names of the products the feed leaves out, in the admin's language
filter_manufacturers — database — manufacturer names, with the feed's own selection and exclusion marked
filter_name — request — what the merchant typed into the picker search
filter_stock_statuses — database — stock status names, with the feed's own selection marked
language_id — database, request and settings — whichever of the three named the language being worked in
language_readout — database — the store's installed languages against what this extension ships
languages — database — core's installed languages
locale — shipped data file — the taxonomy locale that resolved for this language
name — database and request — the feed's name, as the merchant typed it
nodes — shipped data file — the taxonomy children below the path being browsed
page_suggestions — database — how many of this page's categories carry a proposal
pending — database — a count of unsettled proposals
refused — runtime — how many rows the preview run rejected
results — database and shipped data file — what the picker search matched
rows — database — real product rows as the generator would write them, cell by cell
schedule_status — database — whether the feed row's schedule is on
status — database — whether the feed row is on
store_id — database and request — which store is being worked in
stores — database and settings — the store names, config_name standing in for store 0
suggestions — database — the stored proposals, with the category and taxonomy names they name
unsettled — database — a count of categories with no answer yet
url — database — the hosted feed address, with that feed's secret in the query string
user_token — session — core's own admin session token
version — shipped data file — the taxonomy version in use
version_warning — runtime — the store's own OpenCart VERSION, against what this extension is tested on
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 — 86 .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 — 86 .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. not met — 86 .php files:
extensions/product_feed/src/admin/controller/module/product_feed.php:597 — a caught Throwable message is rendered to the response through $data