Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ jobs:
# run no build step), so the autoloader is present straight from checkout.
- name: Params typed accessors
run: php tests/params-typed-accessors.php
- name: Captcha posted token
run: php tests/captcha-posted-token.php
- name: Rewrite param decode
run: php tests/rewrite-param-decode.php
- name: Rewrite route match
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ most of them do not use — so it now goes in the same place as any other third-

### Fixed

- Cloudflare Turnstile (and reCAPTCHA) tokens were passed through HTMLPurifier
before siteverify. The token is opaque, not HTML; purifying it can empty or
alter the value so every captcha check fails. The posted field is now read
unpurified via Params::getParamString($name, false, false) on POST only.
The previous empty-token guard used ORed inequalities and was always true.
- **Deleting a custom field that had been submitted through a form failed.** The delete
removed the field's values, its category assignments and its form memberships, then hit a
foreign key on the submitted values it had not cleared and stopped — leaving the field in
Expand Down
26 changes: 17 additions & 9 deletions oc-includes/osclass/utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -988,13 +988,18 @@ function osc_zip_folder($archive_folder, $archive_name)
*/
function osc_check_recaptcha()
{
$gReCaptchaResponse = Params::getParam('g-recaptcha-response');
if ($gReCaptchaResponse !== '' || $gReCaptchaResponse !== false || $gReCaptchaResponse !== 0) {
$recaptcha = new ReCaptcha(osc_recaptcha_private_key());
$resp = $recaptcha->verify($gReCaptchaResponse, Params::getServerParam('REMOTE_ADDR'));
if ($resp->isSuccess()) {
return true;
}
if (strtoupper((string)Params::getServerParam('REQUEST_METHOD', false, false)) !== 'POST') {
return false;
}
// Opaque token: skip HTMLPurifier (same idiom as installer passwords).
$gReCaptchaResponse = Params::getParamString('g-recaptcha-response', false, false);
if ($gReCaptchaResponse === '') {
return false;
}
$recaptcha = new ReCaptcha(osc_recaptcha_private_key());
$resp = $recaptcha->verify($gReCaptchaResponse, Params::getServerParam('REMOTE_ADDR'));
if ($resp->isSuccess()) {
return true;
}

return false;
Expand All @@ -1016,8 +1021,11 @@ function osc_check_captcha()
case 'recaptcha':
return osc_check_recaptcha();
case 'turnstile':
$token = Params::getParam('cf-turnstile-response');
if (!is_string($token) || $token === '' || strlen($token) > 2048) {
if (strtoupper((string)Params::getServerParam('REQUEST_METHOD', false, false)) !== 'POST') {
return false;
}
$token = Params::getParamString('cf-turnstile-response', false, false);
if ($token === '' || strlen($token) > 2048) {
return false;
}
try {
Expand Down
81 changes: 81 additions & 0 deletions tests/captcha-posted-token.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/*
* This file is part of Shopclass (Mindstellar).
* Copyright (c) 2021-2026 Mindstellar Community
*
* Distributed under the GNU General Public License v3.0 or later. See LICENSE.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/

/**
* Captcha tokens are opaque POST strings and must not go through HTMLPurifier.
* Params::getParam() with defaults would strip or alter characters that
* siteverify then rejects. Call sites use getParamString($name, false, false)
* plus a POST method check — no extra public helper.
*
* DB-free. Usage: php tests/captcha-posted-token.php
*/

if (!defined('ABS_PATH')) {
define('ABS_PATH', dirname(__DIR__) . DIRECTORY_SEPARATOR);
}
if (!defined('OSCLASS_VERSION')) {
define('OSCLASS_VERSION', '0');
}

require_once __DIR__ . '/../oc-includes/vendor/autoload.php';
require_once __DIR__ . '/../oc-includes/osclass/classes/Params.php';
require_once __DIR__ . '/../oc-includes/osclass/utils.php';
require_once __DIR__ . '/lib/harness.php';

$GLOBALS['okCount'] = 0;
$GLOBALS['failCount'] = 0;
$GLOBALS['failLabels'] = array();

$opaque = '0.aaaa.bbbb+cccc/dddd=eeee';
$tagged = '0.aa<bb>cc&dd';

$_SERVER['REQUEST_METHOD'] = 'POST';
$_GET = array('cf-turnstile-response' => 'from-query');
$_POST = array(
'cf-turnstile-response' => $opaque,
'g-recaptcha-response' => $opaque,
);
Params::init();

harness_section('unpurified Params keeps the posted token');
pin('turnstile POST', $opaque, Params::getParamString('cf-turnstile-response', false, false));
pin('recaptcha POST', $opaque, Params::getParamString('g-recaptcha-response', false, false));

harness_section('GET-only is rejected at the captcha call site');
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET = array('g-recaptcha-response' => $opaque, 'cf-turnstile-response' => $opaque);
$_POST = array();
Params::init();
check('osc_check_recaptcha ignores GET', osc_check_recaptcha() === false);

harness_section('non-string POST is not accepted');
$_SERVER['REQUEST_METHOD'] = 'POST';
$_GET = array();
$_POST = array('cf-turnstile-response' => array($opaque));
Params::init();
pin('array POST ignored', '', Params::getParamString('cf-turnstile-response', false, false));

harness_section('missing field');
$_POST = array();
Params::init();
pin('absent', '', Params::getParamString('cf-turnstile-response', false, false));
check('empty POST fails recaptcha', osc_check_recaptcha() === false);

harness_section('HTMLPurifier would alter markup in the same field');
$_GET = array();
$_POST = array('cf-turnstile-response' => $tagged);
Params::init();
pin('raw keeps tags and ampersand', $tagged, Params::getParamString('cf-turnstile-response', false, false));
check(
'default getParam strips or encodes those characters',
Params::getParam('cf-turnstile-response') !== $tagged
);

exit(harness_result());