Skip to content

Commit

Permalink
add security options: email block list and hCaptcha - #558 and #261
Browse files Browse the repository at this point in the history
  • Loading branch information
SergeyMosin committed Nov 12, 2024
1 parent 1cadc73 commit f893a9c
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 11 deletions.
21 changes: 21 additions & 0 deletions lib/Backend/BackendUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => [
[
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
126 changes: 124 additions & 2 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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'] = "";
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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/');
Expand Down Expand Up @@ -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;
}
}
23 changes: 23 additions & 0 deletions lib/Controller/StateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
10 changes: 9 additions & 1 deletion scss/form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion src/components/settings-v2/ComboInput.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup>
import {computed, useSlots} from "vue";
import LabelAccordion from "../LabelAccordion.vue";
import {NcTextField} from "@nextcloud/vue";
import {NcTextField, NcPasswordField} from "@nextcloud/vue";
const slots = useSlots()
const props = defineProps({
Expand Down Expand Up @@ -76,6 +76,17 @@ const handleFocus = (evt) => {
v-model="settings[propName]"
@focus="handleFocus"
@blur="store.setOne(propName,settings[propName])"/>
<NcPasswordField
v-else-if="type==='password'"
:label-outside="true"
:placeholder="placeholder"
class="ps-text-field ps-vert-spacing"
:type="type"
:disabled="disabled || isLoading"
:value.sync="settings[propName]"
@focus="handleFocus"
@keyup.enter="store.setOne(propName, settings[propName])"
@blur="store.setOne(propName, settings[propName])"/>
<NcTextField
v-else
:label-outside="true"
Expand Down
Loading

0 comments on commit f893a9c

Please sign in to comment.