From 74a54e43d1f902ea6828f8ea346c82bdecacf39d Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Wed, 22 Jul 2026 12:01:16 +0200 Subject: [PATCH] default site and secret keys for captchas --- .../{modules/captchas_hcaptcha => }/README.md | 43 ++++++---- .../captchas_recaptcha => }/README.md | 34 +++++--- .../captchas_recaptcha3 => }/README.md | 35 +++++--- .../captchas_turnstile => }/README.md | 40 ++++++---- .../{modules/captchas => }/README.md | 80 ++++++++++++++++--- .../test/captcha/default_secret_test.liquid | 19 +++++ .../test/captcha/default_site_key_test.liquid | 19 +++++ .../lib/test/captcha/verify_build_test.liquid | 12 ++- .../test/captcha/widget_dispatch_test.liquid | 15 +++- .../public/lib/commands/captcha/verify.liquid | 4 +- .../lib/commands/captcha/verify/build.liquid | 13 +-- .../public/lib/helpers/default_secret.liquid | 13 +++ .../lib/helpers/default_site_key.liquid | 14 ++++ .../public/views/partials/widget.liquid | 8 +- 14 files changed, 274 insertions(+), 75 deletions(-) rename pos-module-captchas-hcaptcha/{modules/captchas_hcaptcha => }/README.md (70%) rename pos-module-captchas-recaptcha/{modules/captchas_recaptcha => }/README.md (77%) rename pos-module-captchas-recaptcha3/{modules/captchas_recaptcha3 => }/README.md (81%) rename pos-module-captchas-turnstile/{modules/captchas_turnstile => }/README.md (73%) rename pos-module-captchas/{modules/captchas => }/README.md (74%) create mode 100644 pos-module-captchas/app/lib/test/captcha/default_secret_test.liquid create mode 100644 pos-module-captchas/app/lib/test/captcha/default_site_key_test.liquid create mode 100644 pos-module-captchas/modules/captchas/public/lib/helpers/default_secret.liquid create mode 100644 pos-module-captchas/modules/captchas/public/lib/helpers/default_site_key.liquid diff --git a/pos-module-captchas-hcaptcha/modules/captchas_hcaptcha/README.md b/pos-module-captchas-hcaptcha/README.md similarity index 70% rename from pos-module-captchas-hcaptcha/modules/captchas_hcaptcha/README.md rename to pos-module-captchas-hcaptcha/README.md index 8290afb..c583264 100644 --- a/pos-module-captchas-hcaptcha/modules/captchas_hcaptcha/README.md +++ b/pos-module-captchas-hcaptcha/README.md @@ -18,31 +18,30 @@ This also installs the `captchas` dependency. `pos-cli deploy ` afterwards. ## Keys -Create a site in the [hCaptcha dashboard](https://dashboard.hcaptcha.com/) and store the -pair in constants: +Create a site in the [hCaptcha dashboard](https://dashboard.hcaptcha.com/). **Best +practice**: store the pair in the abstraction's default constants, so the generic +widget/verify calls need neither `provider:`, `site_key:`, nor `secret:` at any call site: ```bash -pos-cli constants set --name CAPTCHA_HCAPTCHA_SITE_KEY --value "10000000-..." -pos-cli constants set --name CAPTCHA_HCAPTCHA_SECRET --value "0x0000..." +pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "hcaptcha" +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "10000000-..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "0x0000..." ``` The site key is public (rendered client-side); the secret is server-side only — never output -it in HTML. The constant names are a convention: keys are always passed by the caller, so -multiple sitekeys per instance are just more constants. +it in HTML. Keys are always passed by the caller (or resolved from the defaults above), so +multiple sitekeys per instance are just another pair of constants, passed explicitly at that +call site instead of relying on the default. ## Usage ```liquid {# in your form #} -{% render 'modules/captchas/widget', - provider: 'hcaptcha', - site_key: context.constants.CAPTCHA_HCAPTCHA_SITE_KEY %} +{% render 'modules/captchas/widget' %} {# in your POST handler #} {% function result = 'modules/captchas/commands/captcha/verify', - provider: 'hcaptcha', - secret: context.constants.CAPTCHA_HCAPTCHA_SECRET, - expected_sitekey: context.constants.CAPTCHA_HCAPTCHA_SITE_KEY %} + expected_sitekey: context.constants.CAPTCHA_DEFAULT_SITE_KEY %} {% if result.valid %}...{% endif %} ``` @@ -50,10 +49,24 @@ multiple sitekeys per instance are just more constants. > against any widget of your account verifies against your secret, so a token issued for one > form could be redeemed on another. `expected_sitekey` is forwarded as hCaptcha's `sitekey` > siteverify param (which hCaptcha itself recommends) so such tokens are rejected. Always set -> it when the account has more than one sitekey. +> it — even with a single default site key — since it costs nothing and future-proofs against +> a second sitekey being added later. + +Pass `provider:`, `site_key:`, and/or `secret:` explicitly only when a call site needs to +deviate from the instance default (see [Keys](#keys)): + +```liquid +{% render 'modules/captchas/widget', + provider: 'hcaptcha', + site_key: context.constants.CAPTCHA_HCAPTCHA_MKTG_SITE_KEY %} + +{% function result = 'modules/captchas/commands/captcha/verify', + provider: 'hcaptcha', + secret: context.constants.CAPTCHA_HCAPTCHA_MKTG_SECRET, + expected_sitekey: context.constants.CAPTCHA_HCAPTCHA_MKTG_SITE_KEY %} +``` -Set `CAPTCHA_DEFAULT_PROVIDER=hcaptcha` to omit the `provider:` argument. The token is read -automatically from `h-captcha-response` in `context.params`. +The token is read automatically from `h-captcha-response` in `context.params`. ## Widget options diff --git a/pos-module-captchas-recaptcha/modules/captchas_recaptcha/README.md b/pos-module-captchas-recaptcha/README.md similarity index 77% rename from pos-module-captchas-recaptcha/modules/captchas_recaptcha/README.md rename to pos-module-captchas-recaptcha/README.md index eb65548..4ed1455 100644 --- a/pos-module-captchas-recaptcha/modules/captchas_recaptcha/README.md +++ b/pos-module-captchas-recaptcha/README.md @@ -23,34 +23,46 @@ This also installs the `captchas` dependency. `pos-cli deploy ` afterwards. ## Keys Register a **v2** site in the [reCAPTCHA admin console](https://www.google.com/recaptcha/admin) -(your own Google account) and store the pair in constants: +(your own Google account). **Best practice**: store the pair in the abstraction's default +constants, so the generic widget/verify calls need neither `provider:`, `site_key:`, nor +`secret:` at any call site: ```bash -pos-cli constants set --name CAPTCHA_RECAPTCHA_SITE_KEY --value "6Le..." -pos-cli constants set --name CAPTCHA_RECAPTCHA_SECRET --value "6Le..." +pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "recaptcha" +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "6Le..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "6Le..." ``` The site key is public (rendered client-side); the secret is server-side only — never output -it in HTML. The constant names are a convention: keys are always passed by the caller, so -multiple key pairs per instance are just more constants. +it in HTML. Keys are always passed by the caller (or resolved from the defaults above), so +multiple key pairs per instance are just another pair of constants, passed explicitly at +that call site instead of relying on the default. ## Usage ```liquid {# in your form #} +{% render 'modules/captchas/widget' %} + +{# in your POST handler #} +{% function result = 'modules/captchas/commands/captcha/verify' %} +{% if result.valid %}...{% endif %} +``` + +Pass `provider:`, `site_key:`, and/or `secret:` explicitly only when a call site needs to +deviate from the instance default (see [Keys](#keys)): + +```liquid {% render 'modules/captchas/widget', provider: 'recaptcha', - site_key: context.constants.CAPTCHA_RECAPTCHA_SITE_KEY %} + site_key: context.constants.CAPTCHA_RECAPTCHA_MKTG_SITE_KEY %} -{# in your POST handler #} {% function result = 'modules/captchas/commands/captcha/verify', provider: 'recaptcha', - secret: context.constants.CAPTCHA_RECAPTCHA_SECRET %} -{% if result.valid %}...{% endif %} + secret: context.constants.CAPTCHA_RECAPTCHA_MKTG_SECRET %} ``` -Set `CAPTCHA_DEFAULT_PROVIDER=recaptcha` to omit the `provider:` argument. The token is read -automatically from `g-recaptcha-response` in `context.params`. +The token is read automatically from `g-recaptcha-response` in `context.params`. ## Widget options diff --git a/pos-module-captchas-recaptcha3/modules/captchas_recaptcha3/README.md b/pos-module-captchas-recaptcha3/README.md similarity index 81% rename from pos-module-captchas-recaptcha3/modules/captchas_recaptcha3/README.md rename to pos-module-captchas-recaptcha3/README.md index 203244b..89d80f3 100644 --- a/pos-module-captchas-recaptcha3/modules/captchas_recaptcha3/README.md +++ b/pos-module-captchas-recaptcha3/README.md @@ -26,41 +26,56 @@ This also installs the `captchas` dependency. `pos-cli deploy ` afterwards. ## Keys Register a **v3** site in the [reCAPTCHA admin console](https://www.google.com/recaptcha/admin) -(your own Google account; v3 has **no public test keys** and keys are tied to your domain) -and store the pair in constants: +(your own Google account; v3 has **no public test keys** and keys are tied to your domain). +**Best practice**: store the pair in the abstraction's default constants, so the generic +widget/verify calls need neither `provider:`, `site_key:`, nor `secret:` at any call site: ```bash -pos-cli constants set --name CAPTCHA_RECAPTCHA3_SITE_KEY --value "6Le..." -pos-cli constants set --name CAPTCHA_RECAPTCHA3_SECRET --value "6Le..." +pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "recaptcha3" +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "6Le..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "6Le..." ``` The site key is public (rendered client-side); the secret is server-side only — never output -it in HTML. +it in HTML. Keys are always passed by the caller (or resolved from the defaults above), so a +second key pair on the same instance is just another pair of constants, passed explicitly at +that call site instead of relying on the default. ## Usage ```liquid {# in your form — invisible: wires the form's submit, no visible widget #} {% parse_json widget_options %}{ "action": "signup_submit" }{% endparse_json %} +{% render 'modules/captchas/widget', options: widget_options %} + +{# in your POST handler #} +{% function result = 'modules/captchas/commands/captcha/verify', + min_score: 0.5, + expected_action: 'signup_submit' %} +{% if result.valid %}...{% endif %} +``` + +Pass `provider:`, `site_key:`, and/or `secret:` explicitly only when a call site needs to +deviate from the instance default (see [Keys](#keys)): + +```liquid {% render 'modules/captchas/widget', provider: 'recaptcha3', - site_key: context.constants.CAPTCHA_RECAPTCHA3_SITE_KEY, + site_key: context.constants.CAPTCHA_RECAPTCHA3_MKTG_SITE_KEY, options: widget_options %} -{# in your POST handler #} {% function result = 'modules/captchas/commands/captcha/verify', provider: 'recaptcha3', - secret: context.constants.CAPTCHA_RECAPTCHA3_SECRET, + secret: context.constants.CAPTCHA_RECAPTCHA3_MKTG_SECRET, min_score: 0.5, expected_action: 'signup_submit' %} -{% if result.valid %}...{% endif %} ``` v3 is **score-based**: every verification returns a score (`0.0` = likely bot, `1.0` = likely human) instead of a binary verdict. `verify` passes only when the provider reports success, `result.score >= min_score` (default `0.5`), and — when `expected_action` is set — the response action matches. `result.score` and `result.action` are returned for logging or -custom thresholds. Set `CAPTCHA_DEFAULT_PROVIDER=recaptcha3` to omit the `provider:` argument. +custom thresholds. ## Widget options diff --git a/pos-module-captchas-turnstile/modules/captchas_turnstile/README.md b/pos-module-captchas-turnstile/README.md similarity index 73% rename from pos-module-captchas-turnstile/modules/captchas_turnstile/README.md rename to pos-module-captchas-turnstile/README.md index f049be4..de13074 100644 --- a/pos-module-captchas-turnstile/modules/captchas_turnstile/README.md +++ b/pos-module-captchas-turnstile/README.md @@ -19,37 +19,49 @@ This also installs the `captchas` dependency. `pos-cli deploy ` afterwards. ## Keys Create a Turnstile widget in the [Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) -(your own account — keys are per-customer, not platform-wide) and store the pair in constants: +(your own account — keys are per-customer, not platform-wide). **Best practice**: store them +in the abstraction's default constants, so the generic widget/verify calls need neither +`provider:`, `site_key:`, nor `secret:` at any call site: ```bash -pos-cli constants set --name CAPTCHA_TURNSTILE_SITE_KEY --value "0x4AAA..." -pos-cli constants set --name CAPTCHA_TURNSTILE_SECRET --value "0x4AAA..." +pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "turnstile" +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "0x4AAA..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "0x4AAA..." ``` The site key is public (rendered client-side); the secret is server-side only — never output -it in HTML. The constant names are a convention: keys are always passed by the caller, so -multiple key pairs per instance (e.g. a separate marketing-site widget) are just more -constants (`CAPTCHA_TURNSTILE_MKTG_SITE_KEY`, ...). +it in HTML. Keys are always passed by the caller (or resolved from the defaults above), so +multiple key pairs per instance (e.g. a separate marketing-site widget) are just another pair +of constants, passed explicitly at that call site instead of relying on the default +(`CAPTCHA_TURNSTILE_MKTG_SITE_KEY`, ...). ## Usage ```liquid {# in your form #} +{% render 'modules/captchas/widget' %} + +{# in your POST handler #} +{% function result = 'modules/captchas/commands/captcha/verify' %} +{% if result.valid %}...{% endif %} +``` + +Pass `provider:`, `site_key:`, and/or `secret:` explicitly only when a call site needs to +deviate from the instance default (see [Keys](#keys)): + +```liquid {% render 'modules/captchas/widget', provider: 'turnstile', - site_key: context.constants.CAPTCHA_TURNSTILE_SITE_KEY %} + site_key: context.constants.CAPTCHA_TURNSTILE_MKTG_SITE_KEY %} -{# in your POST handler #} {% function result = 'modules/captchas/commands/captcha/verify', provider: 'turnstile', - secret: context.constants.CAPTCHA_TURNSTILE_SECRET %} -{% if result.valid %}...{% endif %} + secret: context.constants.CAPTCHA_TURNSTILE_MKTG_SECRET %} ``` -Set `CAPTCHA_DEFAULT_PROVIDER=turnstile` to omit the `provider:` argument. The token is read -automatically from `cf-turnstile-response` in `context.params`; if you rename the widget's -response field via its `response_field_name` option, pass the same value to `verify` as -`response_field_name:`. +The token is read automatically from `cf-turnstile-response` in `context.params`; if you +rename the widget's response field via its `response_field_name` option, pass the same value +to `verify` as `response_field_name:`. ## Widget options diff --git a/pos-module-captchas/modules/captchas/README.md b/pos-module-captchas/README.md similarity index 74% rename from pos-module-captchas/modules/captchas/README.md rename to pos-module-captchas/README.md index 3207b81..5dc335f 100644 --- a/pos-module-captchas/modules/captchas/README.md +++ b/pos-module-captchas/README.md @@ -41,14 +41,19 @@ pos-cli deploy The module **stores no keys**. You pass the **public site key** to the widget (rendered client-side) and the **secret key** to `verify` (server-side only), typically read from -**platformOS constants**. Because keys are passed per call, **multiple keys for the same -provider on one instance** is trivial — different forms simply pass different keys: +**platformOS constants**. For the common case of one widget/site per instance — **the best +practice** — set `CAPTCHA_DEFAULT_SITE_KEY` and `CAPTCHA_DEFAULT_SECRET` once (see [Site key +selection](#site-key-selection) and [Secret selection](#secret-selection)) and skip passing +`site_key`/`secret` at every call site. Don't invent a per-provider constant name +(`CAPTCHA_TURNSTILE_SITE_KEY` and friends) unless you actually need one — that naming exists +only for the **multiple keys on one instance** case, where extra forms pass their own key +explicitly instead of relying on the default: ```bash -pos-cli constants set --name CAPTCHA_TURNSTILE_SITE_KEY --value "0x4AAA..." -pos-cli constants set --name CAPTCHA_TURNSTILE_SECRET --value "0x4AAA..." +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "0x4AAA..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "0x4AAA..." -# A second widget/site on the same instance — just another pair of constants: +# A second widget/site on the same instance — an extra pair of constants, passed explicitly: pos-cli constants set --name CAPTCHA_TURNSTILE_MKTG_SITE_KEY --value "0x4AAA..." pos-cli constants set --name CAPTCHA_TURNSTILE_MKTG_SECRET --value "0x4AAA..." ``` @@ -60,21 +65,46 @@ Each provider module's README documents its constant-name convention and where t ## Usage +**Best practice**: set `CAPTCHA_DEFAULT_PROVIDER`, `CAPTCHA_DEFAULT_SITE_KEY`, and +`CAPTCHA_DEFAULT_SECRET` once per instance, then invoke the widget and `verify` with none of +`provider`, `site_key`, or `secret` — all three resolve from constants. No per-provider +constant name to invent, and swapping providers later is a constant change, not a template +change: + +```bash +pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "turnstile" +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "0x4AAA..." +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "0x4AAA..." +``` + ```liquid {# 1. in your form #} -{% render 'modules/captchas/widget', - provider: 'turnstile', - site_key: context.constants.CAPTCHA_TURNSTILE_SITE_KEY %} +{% render 'modules/captchas/widget' %} {# 2. in your POST handler #} {% liquid - function result = 'modules/captchas/commands/captcha/verify', provider: 'turnstile', secret: context.constants.CAPTCHA_TURNSTILE_SECRET + function result = 'modules/captchas/commands/captcha/verify' if result.valid # proceed endif %} ``` +Pass `provider`, `site_key`, and/or `secret` explicitly only when a call site needs to +deviate from the instance default — e.g. a second widget on the same page using a different +provider or key (see [Keys are caller-supplied](#keys-are-caller-supplied)): + +```liquid +{% render 'modules/captchas/widget', + provider: 'turnstile', + site_key: context.constants.CAPTCHA_TURNSTILE_MKTG_SITE_KEY %} + +{% liquid + function result = 'modules/captchas/commands/captcha/verify', + provider: 'turnstile', secret: context.constants.CAPTCHA_TURNSTILE_MKTG_SECRET +%} +``` + Render errors with `| t` — `result.errors` maps fields to arrays of translation keys: ```liquid @@ -98,6 +128,29 @@ normalized (trimmed, lowercased). pos-cli constants set --name CAPTCHA_DEFAULT_PROVIDER --value "turnstile" ``` +### Site key selection + +`site_key` (widget only) may be passed explicitly. If omitted, it resolves to the +`CAPTCHA_DEFAULT_SITE_KEY` constant. There is no built-in fallback — with neither set, the +widget logs an error and emits an HTML comment instead (see [the widget +reference](#partial-modulescaptchaswidget)). Only whitespace is trimmed — unlike the provider +key, site keys are case-sensitive. + +```bash +pos-cli constants set --name CAPTCHA_DEFAULT_SITE_KEY --value "0x4AAA..." +``` + +### Secret selection + +`secret` (verify only) may be passed explicitly. If omitted, it resolves to the +`CAPTCHA_DEFAULT_SECRET` constant. There is no built-in fallback — with neither set, `verify` +fails closed with `secret_missing` (no network call). Only whitespace is trimmed — secrets +are case-sensitive. + +```bash +pos-cli constants set --name CAPTCHA_DEFAULT_SECRET --value "0x4AAA..." +``` + A provider is **available** when its module (`captchas_`) is installed — detected via the `provider.name` translation every provider module ships. Optionally, `CAPTCHA_ENABLED_PROVIDERS` (CSV, e.g. `turnstile,hcaptcha`) pins an allow-list: when set, @@ -115,7 +168,7 @@ a logged HTML comment, and verify returns `valid: false` with | Param | Required | Description | |---|---|---| -| `site_key` | yes | The provider's public site key. Blank logs an error + emits an HTML comment (the widget is still rendered and fails visibly client-side). | +| `site_key` | no | The provider's public site key; defaults to `CAPTCHA_DEFAULT_SITE_KEY`. A blank result (neither passed nor set) logs an error + emits an HTML comment (the widget is still rendered and fails visibly client-side). | | `provider` | no | Provider key; defaults to `CAPTCHA_DEFAULT_PROVIDER`. | | `options` | no | Hash of provider-specific widget options — see the provider module's README. Build it with `parse_json`/`hash_merge`; inline hash literals are nil at runtime. | @@ -124,7 +177,7 @@ a logged HTML comment, and verify returns `valid: false` with | Param | Required | Description | |---|---|---| | `provider` | no | Provider key; defaults to `CAPTCHA_DEFAULT_PROVIDER`. | -| `secret` | yes | Provider secret key (server-side; read from a constant by the caller). | +| `secret` | no | Provider secret key (server-side); defaults to `CAPTCHA_DEFAULT_SECRET`. A blank result (neither passed nor set) fails closed with `secret_missing`. | | `token` | no | The widget token; defaults to the provider's response field in `context.params`. | | `response_field_name` | no | Form field the widget wrote the token to; overrides the provider default (only needed if you set the widget's `response_field_name` option). | | `remote_ip` | no | Optional visitor IP forwarded to the provider. | @@ -218,7 +271,8 @@ pos-cli deploy pos-cli test run ``` -The suite assumes `CAPTCHA_DEFAULT_PROVIDER` is unset and `CAPTCHA_ENABLED_PROVIDERS` is -unset (or contains `test`) on the test instance. Provider-specific tests — real siteverify +The suite assumes `CAPTCHA_DEFAULT_PROVIDER`, `CAPTCHA_DEFAULT_SITE_KEY`, and +`CAPTCHA_DEFAULT_SECRET` are unset, and `CAPTCHA_ENABLED_PROVIDERS` is unset (or contains +`test`), on the test instance. Provider-specific tests — real siteverify round-trips against public test keys, score thresholds — live in each provider module's repo. diff --git a/pos-module-captchas/app/lib/test/captcha/default_secret_test.liquid b/pos-module-captchas/app/lib/test/captcha/default_secret_test.liquid new file mode 100644 index 0000000..88ea94a --- /dev/null +++ b/pos-module-captchas/app/lib/test/captcha/default_secret_test.liquid @@ -0,0 +1,19 @@ +{% comment %} + Unit test (no network): captcha secret-resolution helper. + Deterministic — does not call any siteverify endpoint. + Assumes CAPTCHA_DEFAULT_SECRET is not set in the test environment: the abstraction ships + no secret of its own, so the no-argument case resolves to blank. +{% endcomment %} +{% liquid + # an explicit secret is returned as-is + function explicit = 'modules/captchas/helpers/default_secret', secret: 'sec-explicit-123' + function contract = 'modules/tests/assertions/equal', contract: contract, given: explicit, expected: 'sec-explicit-123', field_name: 'explicit_secret' + + # no secret + no constant -> blank (no hardcoded secret fallback in the abstraction) + function fallback = 'modules/captchas/helpers/default_secret' + function contract = 'modules/tests/assertions/equal', contract: contract, given: fallback, expected: '', field_name: 'fallback_is_blank' + + # only whitespace is trimmed - secrets are case-sensitive + function trimmed = 'modules/captchas/helpers/default_secret', secret: ' Sec-Mixed-Case ' + function contract = 'modules/tests/assertions/equal', contract: contract, given: trimmed, expected: 'Sec-Mixed-Case', field_name: 'trimmed_not_downcased' +%} diff --git a/pos-module-captchas/app/lib/test/captcha/default_site_key_test.liquid b/pos-module-captchas/app/lib/test/captcha/default_site_key_test.liquid new file mode 100644 index 0000000..1a1897e --- /dev/null +++ b/pos-module-captchas/app/lib/test/captcha/default_site_key_test.liquid @@ -0,0 +1,19 @@ +{% comment %} + Unit test (no network): captcha site-key-resolution helper. + Deterministic — does not call any siteverify endpoint. + Assumes CAPTCHA_DEFAULT_SITE_KEY is not set in the test environment: the abstraction ships + no site key of its own, so the no-argument case resolves to blank. +{% endcomment %} +{% liquid + # an explicit site key is returned as-is + function explicit = 'modules/captchas/helpers/default_site_key', site_key: 'sk-explicit-123' + function contract = 'modules/tests/assertions/equal', contract: contract, given: explicit, expected: 'sk-explicit-123', field_name: 'explicit_site_key' + + # no site key + no constant -> blank (no hardcoded site key fallback in the abstraction) + function fallback = 'modules/captchas/helpers/default_site_key' + function contract = 'modules/tests/assertions/equal', contract: contract, given: fallback, expected: '', field_name: 'fallback_is_blank' + + # only whitespace is trimmed - unlike the provider key, site keys are case-sensitive + function trimmed = 'modules/captchas/helpers/default_site_key', site_key: ' Sk-Mixed-Case ' + function contract = 'modules/tests/assertions/equal', contract: contract, given: trimmed, expected: 'Sk-Mixed-Case', field_name: 'trimmed_not_downcased' +%} diff --git a/pos-module-captchas/app/lib/test/captcha/verify_build_test.liquid b/pos-module-captchas/app/lib/test/captcha/verify_build_test.liquid index 8c56d11..cf48d25 100644 --- a/pos-module-captchas/app/lib/test/captcha/verify_build_test.liquid +++ b/pos-module-captchas/app/lib/test/captcha/verify_build_test.liquid @@ -1,8 +1,8 @@ {% comment %} Unit test (no network): captcha verify BUILD phase. Deterministic — build only normalizes inputs; it never calls a siteverify endpoint. - Uses the fake provider (app/modules/captchas_test); assumes CAPTCHA_DEFAULT_PROVIDER is - not set in the test environment. + Uses the fake provider (app/modules/captchas_test); assumes CAPTCHA_DEFAULT_PROVIDER and + CAPTCHA_DEFAULT_SECRET are not set in the test environment. {% endcomment %} {% liquid # no provider + no constant -> provider stays blank; build still returns a valid object @@ -12,6 +12,14 @@ function contract = 'modules/tests/assertions/equal', contract: contract, given: b1.provider, expected: '', field_name: 'no_provider_is_blank' function contract = 'modules/tests/assertions/valid_object', contract: contract, object: b1, field_name: 'build_sets_valid' + # no secret + no constant -> secret stays blank (the check phase is what rejects it) + function contract = 'modules/tests/assertions/equal', contract: contract, given: b1.secret, expected: '', field_name: 'no_secret_is_blank' + + # an explicit secret is preserved through build + assign o1b = '{}' | parse_json | hash_merge: secret: 'sk_explicit_test' + function b1b = 'modules/captchas/commands/captcha/verify/build', object: o1b + function contract = 'modules/tests/assertions/equal', contract: contract, given: b1b.secret, expected: 'sk_explicit_test', field_name: 'explicit_secret_preserved' + # an explicit provider is preserved, and the default token field comes from the provider # module's config helper (dynamic dispatch) assign o2 = '{}' | parse_json | hash_merge: provider: 'test' diff --git a/pos-module-captchas/app/lib/test/captcha/widget_dispatch_test.liquid b/pos-module-captchas/app/lib/test/captcha/widget_dispatch_test.liquid index 4d2823a..8c1c185 100644 --- a/pos-module-captchas/app/lib/test/captcha/widget_dispatch_test.liquid +++ b/pos-module-captchas/app/lib/test/captcha/widget_dispatch_test.liquid @@ -1,7 +1,8 @@ {% comment %} Unit test (no network): the widget partial dispatches dynamically to the provider module's widget (fake provider at app/modules/captchas_test) and degrades to a logged HTML comment — - never a hard error — when the provider is unavailable or the site key is blank. + never a hard error — when the provider is unavailable or the site key is blank. Assumes + CAPTCHA_DEFAULT_SITE_KEY is not set in the test environment. {% endcomment %} {% liquid # available provider -> provider widget rendered with the passed site_key @@ -36,4 +37,16 @@ assign blank_warned = true endif function contract = 'modules/tests/assertions/equal', contract: contract, given: blank_warned, expected: true, field_name: 'blank_site_key_warns' + + # omitted site_key (no constant set) -> resolves via helpers/default_site_key to blank, + # same as an explicit blank value; proves the widget actually calls the default-resolution + # helper rather than relying on the caller to pass site_key at all + capture rendered_omitted + render 'modules/captchas/widget', provider: 'test' + endcapture + assign omitted_warned = false + if rendered_omitted contains 'blank site_key' and rendered_omitted contains 'data-captcha-test' + assign omitted_warned = true + endif + function contract = 'modules/tests/assertions/equal', contract: contract, given: omitted_warned, expected: true, field_name: 'omitted_site_key_resolves_blank' %} diff --git a/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify.liquid b/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify.liquid index 36f22ed..b7e3edc 100644 --- a/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify.liquid +++ b/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify.liquid @@ -2,7 +2,9 @@ @description Verify a captcha token server-side and return a normalized pass/fail. @param {string} [provider] - provider key; defaults to the CAPTCHA_DEFAULT_PROVIDER constant. Dispatches to the modules/captchas_ module — see helpers/provider_available. - @param {string} secret - provider secret key (server-side; read from a constant by the caller). + @param {string} [secret] - provider secret key (server-side); defaults to the + CAPTCHA_DEFAULT_SECRET constant. Typically left to the default rather than read from a + per-provider constant by the caller. @param {string} [token] - the widget token; defaults to the provider's field in context.params. @param {string} [response_field_name] - name of the form field the widget writes the token to; overrides the provider default (only needed when you set the widget's response_field_name option). diff --git a/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify/build.liquid b/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify/build.liquid index f936bff..6554881 100644 --- a/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify/build.liquid +++ b/pos-module-captchas/modules/captchas/public/lib/commands/captcha/verify/build.liquid @@ -1,12 +1,15 @@ {% doc %} - @description Build phase for captcha verify: resolve provider, locate the token field, and normalize inputs. + @description Build phase for captcha verify: resolve provider, secret, locate the token + field, and normalize inputs. @param {object} object - raw input: { provider, secret, token, remote_ip, response_field_name }. - @returns {object} normalized object with provider, token, token_field (the form field the token - was read from — explicit response_field_name, else the provider config's response_field, - else blank when the provider is unavailable), secret, remote_ip, valid, errors. + @returns {object} normalized object with provider, secret (explicit value, else the + CAPTCHA_DEFAULT_SECRET constant), token, token_field (the form field the token was read + from — explicit response_field_name, else the provider config's response_field, else blank + when the provider is unavailable), remote_ip, valid, errors. {% enddoc %} {% liquid function provider = 'modules/captchas/helpers/default_provider', provider: object.provider + function secret = 'modules/captchas/helpers/default_secret', secret: object.secret # The default token field name is provider knowledge: ask the provider module's config # helper for it. Only dispatch when the provider is available — building a @@ -28,6 +31,6 @@ endif assign empty_errors = '{}' | parse_json - assign object = object | hash_merge: provider: provider, token: token, token_field: token_field, valid: true, errors: empty_errors + assign object = object | hash_merge: provider: provider, secret: secret, token: token, token_field: token_field, valid: true, errors: empty_errors return object %} diff --git a/pos-module-captchas/modules/captchas/public/lib/helpers/default_secret.liquid b/pos-module-captchas/modules/captchas/public/lib/helpers/default_secret.liquid new file mode 100644 index 0000000..b152030 --- /dev/null +++ b/pos-module-captchas/modules/captchas/public/lib/helpers/default_secret.liquid @@ -0,0 +1,13 @@ +{% doc %} + @description Resolve the active provider secret. Mirrors helpers/default_site_key: an + explicit value always wins; otherwise falls back to the CAPTCHA_DEFAULT_SECRET constant. + Only whitespace is trimmed — secrets are case-sensitive. + @param {string} [secret] Explicit secret; used when present. + @returns {string} secret — explicit value, else the CAPTCHA_DEFAULT_SECRET constant, else ''. +{% enddoc %} +{% liquid + assign resolved = secret | strip + assign resolved = resolved | default: context.constants.CAPTCHA_DEFAULT_SECRET | strip + assign resolved = resolved | default: '' + return resolved +%} diff --git a/pos-module-captchas/modules/captchas/public/lib/helpers/default_site_key.liquid b/pos-module-captchas/modules/captchas/public/lib/helpers/default_site_key.liquid new file mode 100644 index 0000000..573cc4c --- /dev/null +++ b/pos-module-captchas/modules/captchas/public/lib/helpers/default_site_key.liquid @@ -0,0 +1,14 @@ +{% doc %} + @description Resolve the active site key. Mirrors helpers/default_provider: an explicit + value always wins; otherwise falls back to the CAPTCHA_DEFAULT_SITE_KEY constant. Only + whitespace is trimmed — unlike the provider key, site keys are case-sensitive and are not + downcased. + @param {string} [site_key] Explicit site key; used when present. + @returns {string} site key — explicit value, else the CAPTCHA_DEFAULT_SITE_KEY constant, else ''. +{% enddoc %} +{% liquid + assign resolved = site_key | strip + assign resolved = resolved | default: context.constants.CAPTCHA_DEFAULT_SITE_KEY | strip + assign resolved = resolved | default: '' + return resolved +%} diff --git a/pos-module-captchas/modules/captchas/public/views/partials/widget.liquid b/pos-module-captchas/modules/captchas/public/views/partials/widget.liquid index 99c9906..2db834c 100644 --- a/pos-module-captchas/modules/captchas/public/views/partials/widget.liquid +++ b/pos-module-captchas/modules/captchas/public/views/partials/widget.liquid @@ -3,14 +3,16 @@ naming convention to the provider module's modules/captchas_/widget partial — the abstraction knows no providers. Render this partial (do not include it): provider widgets are pulled in via include and would otherwise leak their internals into your scope. - @param {string} site_key - The provider's public site key. A blank value (e.g. an unset - constant) logs an error and emits an HTML comment; the widget is still rendered and fails - visibly in the provider's client-side script. + @param {string} [site_key] - The provider's public site key; defaults to the + CAPTCHA_DEFAULT_SITE_KEY constant. A blank result (neither passed nor set) logs an error + and emits an HTML comment; the widget is still rendered and fails visibly in the + provider's client-side script. @param {string} [provider] - Provider key; defaults to the CAPTCHA_DEFAULT_PROVIDER constant. @param {object} [options] - Provider widget options, passed through to the provider partial. {% enddoc %} {%- liquid function active_provider = 'modules/captchas/helpers/default_provider', provider: provider + function site_key = 'modules/captchas/helpers/default_site_key', site_key: site_key if site_key == blank # The most common misconfiguration: an unset site-key constant. Surface it like the