diff --git a/lib/Backend/BackendUtils.php b/lib/Backend/BackendUtils.php index d3d8645a..9e451358 100644 --- a/lib/Backend/BackendUtils.php +++ b/lib/Backend/BackendUtils.php @@ -192,6 +192,11 @@ class BackendUtils public const REMINDER_CLI_URL = "cliUrl"; public const REMINDER_LANG = "defaultLang"; + public const SEC_HCAP_SITE_KEY = "secHcapSiteKey"; + public const SEC_HCAP_SECRET = "secHcapSecret"; + public const SEC_HCAP_ENABLED = "secHcapEnabled"; + public const SEC_EMAIL_BLACKLIST = "secEmailBlacklist"; + public const DEBUGGING_MODE = "debugging_mode"; public const DEBUGGING_NONE = 0; public const DEBUGGING_LOG_REM_BLOCKER = 1; @@ -1405,6 +1410,11 @@ function getDefaultSettingsData(): array self::BBB_FORM_ENABLED => false, self::BBB_INTEGRATION_DISABLED => false, + self::SEC_HCAP_SITE_KEY => '', + self::SEC_HCAP_SECRET => '', + self::SEC_HCAP_ENABLED => false, + self::SEC_EMAIL_BLACKLIST => [], + self::KEY_REMINDERS => [ self::REMINDER_DATA => [ [ @@ -1692,6 +1702,12 @@ function setUserSettingsV2(string $userId, string $pageId, string $key, $value): if ($key === self::KEY_REMINDERS) { return $this->setUserReminders($userId, $pageId, $value); + } elseif ($key === self::SEC_HCAP_SECRET && !empty($value)) { + if(str_starts_with($value, '::hash::')){ + // already hashed + return [200, '']; + } + $value = '::hash::' . $this->encrypt($value, $this->getLocalHash()); } $settings = $this->settings; @@ -2075,6 +2091,11 @@ function decrypt(string $data, string $key, string $iv = ''): string return $t === false ? '' : $t; } + public function getLocalHash(): string + { + return hash('md5', \OC_Util::getInstanceId() . $this->config->getAppValue(Application::APP_ID, 'hk', Application::APP_ID), true); + } + function pubPrx(string $token, bool $embed): string { diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 3ded55e9..c2b1cc88 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -7,6 +7,7 @@ namespace OCA\Appointments\Controller; use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OCA\Appointments\AppInfo\Application; use OCA\Appointments\Backend\BackendManager; use OCA\Appointments\Backend\BackendUtils; @@ -17,6 +18,7 @@ use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Template\PublicTemplateResponse; +use OCP\Http\Client\IClientService; use OCP\IConfig; use OCP\IGroupManager; use OCP\IL10N; @@ -680,6 +682,10 @@ public function showFormPost(string $userId, string $pageId, bool $embed = false $ok_uri = "form?sts=0" . $pageParam; $bad_input_url = "form?sts=1" . $pageParam; $server_err_url = "form?sts=2" . $pageParam; + $captcha_failed_url = "form?sts=3" . $pageParam; + $captcha_server_error_url = "form?sts=4" . $pageParam; + $blocked_error_url = "form?sts=5" . $pageParam; + $key = hex2bin($this->c->getAppValue($this->appName, 'hk')); if (empty($key)) { @@ -723,6 +729,23 @@ public function showFormPost(string $userId, string $pageId, bool $embed = false $rr->setStatus(303); return $rr; } + + if (!empty($settings[BackendUtils::SEC_EMAIL_BLACKLIST]) && is_array($settings[BackendUtils::SEC_EMAIL_BLACKLIST])) { + $_email = $post['email']; + foreach ($settings[BackendUtils::SEC_EMAIL_BLACKLIST] as $blocked) { + if ($_email === $blocked || + // domain block + (str_starts_with($blocked, '*@') + && str_ends_with($_email, substr($blocked, 1))) + ) { + $rr = new RedirectResponse($blocked_error_url); + $rr->setStatus(303); + return $rr; + + } + } + } + if ($hide_phone) { $post['phone'] = ""; } @@ -762,6 +785,21 @@ public function showFormPost(string $userId, string $pageId, bool $embed = false } $post['_more_data'] = $v; + if ($settings[BackendUtils::SEC_HCAP_ENABLED] === true + && !empty($settings[BackendUtils::SEC_HCAP_SECRET]) + && !empty($settings[BackendUtils::SEC_HCAP_SITE_KEY]) + ) { + if (($cErr = $this->validateHCaptcha($post, $settings)) !== 0) { + if ($cErr === 1) { + $rr = new RedirectResponse($captcha_failed_url); + } else { + $rr = new RedirectResponse($captcha_server_error_url); + } + $rr->setStatus(303); + return $rr; + } + } + // Input seems OK... $cal_id = $this->utils->getMainCalId($userId, $this->bc); @@ -904,7 +942,7 @@ private function showFormCustomField(array $field, array $post, int $index = 0): private function showFinish(string $render, string $uid): Response { // Redirect to finalize page... - // sts: 0=OK, 1=bad input, 2=server error + // sts: 0=OK, 1=bad input, 2=server error, 3=bad captcha, 4=captcha server error, 5=blocked // sts=2&r=1: race condition while booking // d=time and email @@ -946,6 +984,12 @@ private function showFinish(string $render, string $uid): Response $rs = 409; } } + } elseif ($sts === '3') { + $param['input_err'] = $this->l->t("Human verification failed"); + } elseif ($sts === '4') { + $param['input_err'] = $this->l->t("Internal server error: validation request failed"); + } elseif ($sts === '5') { + $param['input_err'] = $this->l->t('We regret to inform you that your email address has been blocked due to activity that violates our community guidelines.'); } if ($render === "public") { @@ -1000,9 +1044,32 @@ private function showForm(string $render, string $uid, string $pageId): Response 'appt_hide_phone' => $settings[BackendUtils::PSN_HIDE_TEL], 'more_html' => '', 'application' => $this->l->t('Appointments'), - 'translations' => '' + 'translations' => '', + 'hCapKey' => '', ]; + if ($settings[BackendUtils::SEC_HCAP_ENABLED] === true + && !empty($settings[BackendUtils::SEC_HCAP_SECRET]) + && !empty($settings[BackendUtils::SEC_HCAP_SITE_KEY]) + ) { + Util::addHeader("script", [ + 'src' => 'https://www.hCaptcha.com/1/api.js', + 'async' => '', + 'nonce' => \OC::$server->get(ContentSecurityPolicyNonceManager::class)->getNonce() + ], ''); + $csp = $tr->getContentSecurityPolicy(); + $csp->addAllowedScriptDomain('https://hcaptcha.com/'); + $csp->addAllowedScriptDomain('https://*.hcaptcha.com/'); + $csp->addAllowedFrameDomain('https://hcaptcha.com/'); + $csp->addAllowedFrameDomain('https://*.hcaptcha.com/'); + $csp->addAllowedStyleDomain('https://hcaptcha.com/'); + $csp->addAllowedStyleDomain('https://*.hcaptcha.com/'); + $csp->addAllowedConnectDomain('https://hcaptcha.com/'); + $csp->addAllowedConnectDomain('https://*.hcaptcha.com/'); + $tr->setContentSecurityPolicy($csp); + $params['hCapKey'] = $settings[BackendUtils::SEC_HCAP_SITE_KEY]; + } + // google recaptcha // 'jsfiles'=>['https://www.google.com/recaptcha/api.js'] // $tr->getContentSecurityPolicy()->addAllowedScriptDomain('https://www.google.com/recaptcha/')->addAllowedScriptDomain('https://www.gstatic.com/recaptcha/')->addAllowedFrameDomain('https://www.google.com/recaptcha/'); @@ -1321,4 +1388,59 @@ private function throwIfPrivateModeNotLoggedIn(): void throw new NotLoggedInException(); } } + + /** + * @param array $post + * @return int + * 0 = OK, + * 1 = captcha error, + * 2 = internal error + */ + private function validateHCaptcha(array $post, array $settings): int + { + if (empty($post['h-captcha-response'])) { + return 1; + } + + $clientService = \OC::$server->get(IClientService::class); + $client = $clientService->newClient(); + + try { + $res = $client->post('https://api.hcaptcha.com/siteverify', [ + 'form_params' => [ + 'response' => $post['h-captcha-response'], + 'secret' => $this->utils->decrypt(substr($settings[BackendUtils::SEC_HCAP_SECRET], 8), $this->utils->getLocalHash()), + 'sitekey' => $settings[BackendUtils::SEC_HCAP_SITE_KEY] + ]] + ); + + $body = json_decode($res->getBody(), true); + if ($body === null) { + throw new \Exception("cannot parse response"); + } + } catch (\Throwable $e) { + $this->logger->error("hCaptcha post error: ", [ + 'app' => Application::APP_ID, + 'exception' => $e + ]); + return 2; + } + + if ($body['success'] === true) { + return 0; + } + + if (isset($body['error-codes']) && count(array_intersect([ + 'missing-input-secret', + 'invalid-input-secret', + 'sitekey-secret-mismatch' + ], $body['error-codes'])) !== 0) { + + $this->logger->error("hCaptcha internal error: " . var_export($body['error-codes'], true), [ + 'app' => Application::APP_ID + ]); + return 2; + } + return 1; + } } diff --git a/lib/Controller/StateController.php b/lib/Controller/StateController.php index 4ad32dce..cdee93b6 100644 --- a/lib/Controller/StateController.php +++ b/lib/Controller/StateController.php @@ -589,6 +589,29 @@ private function getValidator(string $prop): ?\Closure return $this->utils->setUserSettingsV2($this->userId, $pageId, BackendUtils::TALK_ENABLED, false); }, + BackendUtils::SEC_EMAIL_BLACKLIST => function ($value, $pageId, $key) { + if (!is_array($value)) { + return [Http::STATUS_BAD_REQUEST, '']; + } + foreach ($value as $item) { + if (strlen($item) < 4) { + return [Http::STATUS_BAD_REQUEST, '']; + } + if (str_starts_with($item, '*@')) { + // should be a valid domain after the '*@' + if (filter_var(substr($item, 2), FILTER_VALIDATE_DOMAIN,FILTER_FLAG_HOSTNAME) === false) { + return [Http::STATUS_BAD_REQUEST, '']; + } + } else { + // this should be a valid email + if (filter_var($item, FILTER_VALIDATE_EMAIL) === false) { + return [Http::STATUS_BAD_REQUEST, '']; + } + } + } + return [Http::STATUS_OK, '']; + }, + default => null, }; } diff --git a/scss/form.scss b/scss/form.scss index 8bf31826..bd50c6ca 100644 --- a/scss/form.scss +++ b/scss/form.scss @@ -64,10 +64,18 @@ body { } .srgdev-ncfp-form-main-cont { - width: 18em; + min-width: 18em; + max-width: 22em; margin: 0 auto; } +.h-captcha { + min-width: 303px; + min-height: 78px; + margin-top: 1em; + margin-bottom: -.5em; +} + .srgdev-ncfp-chb-cont, .srgdev-ncfp-form-label { display: block; diff --git a/src/components/settings-v2/ComboInput.vue b/src/components/settings-v2/ComboInput.vue index deb3be26..c042c6c1 100644 --- a/src/components/settings-v2/ComboInput.vue +++ b/src/components/settings-v2/ComboInput.vue @@ -1,7 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/components/settings-v2/Settings.vue b/src/components/settings-v2/Settings.vue index 7bc74ae3..d5dfa2d1 100644 --- a/src/components/settings-v2/Settings.vue +++ b/src/components/settings-v2/Settings.vue @@ -23,6 +23,7 @@ import {parsePageUrls} from "../../use/utils"; import {showError} from "@nextcloud/dialogs"; import {usePagesStore} from "../../stores/pages"; import {useSettingsStore, LOADING_ALL, readOnlyProps} from "../../stores/settings"; +import SectionSecurity from "./SectionSecurity.vue"; const settingsStore = useSettingsStore() @@ -172,6 +173,15 @@ const handleCKey = () => { + +

+ {{ t('appointments', 'Security related settings') }} +

+ +
+ diff --git a/src/stores/settings.js b/src/stores/settings.js index ae046090..67f9706b 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -96,6 +96,11 @@ export const useSettingsStore = defineStore('settings', { fi_html: '', fi_json: [], + secHcapSiteKey: '', + secHcapSecret: '', + secHcapEnabled: false, + secEmailBlacklist: [], + reminders_data_0_seconds: '0', reminders_data_0_actions: false, reminders_data_1_seconds: '0', diff --git a/templates/public/form.php b/templates/public/form.php index 67975423..bc70ae16 100644 --- a/templates/public/form.php +++ b/templates/public/form.php @@ -71,6 +71,9 @@ } echo ''; } + if (!empty($_['hCapKey'])) { + echo '
'; + } ?>