Skip to content

Commit

Permalink
Add hCaptcha.com support to public subscription form. (#1152)
Browse files Browse the repository at this point in the history
Bots easily bypass the simple `nonce` hack. This commit adds support
for the hcaptcha.com widget.

- New `Security` tab in the admin settings UI.
- Enable/disable CAPTCHA.
- Render CAPTCHA on the public subscription form.

Closes #1116.
  • Loading branch information
knadh committed Jan 23, 2023
1 parent 62d3782 commit 8985e5c
Show file tree
Hide file tree
Showing 36 changed files with 374 additions and 3 deletions.
15 changes: 15 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/bounce"
"github.com/knadh/listmonk/internal/bounce/mailbox"
"github.com/knadh/listmonk/internal/captcha"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
Expand Down Expand Up @@ -69,6 +70,11 @@ type constants struct {
Exportable map[string]bool `koanf:"-"`
DomainBlocklist map[string]bool `koanf:"-"`
} `koanf:"privacy"`
Security struct {
EnableCaptcha bool `koanf:"enable_captcha"`
CaptchaKey string `koanf:"captcha_key"`
CaptchaSecret string `koanf:"captcha_secret"`
} `koanf:"security"`
AdminUsername []byte `koanf:"admin_username"`
AdminPassword []byte `koanf:"admin_password"`

Expand Down Expand Up @@ -351,6 +357,9 @@ func initConstants() *constants {
if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
lo.Fatalf("error loading app.privacy config: %v", err)
}
if err := ko.Unmarshal("security", &c.Security); err != nil {
lo.Fatalf("error loading app.security config: %v", err)
}
if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
lo.Fatalf("error loading app.appearance config: %v", err)
}
Expand Down Expand Up @@ -735,6 +744,12 @@ func initHTTPServer(app *App) *echo.Echo {
return srv
}

func initCaptcha() *captcha.Captcha {
return captcha.New(captcha.Opt{
CaptchaSecret: ko.String("security.captcha_secret"),
})
}

func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) chan bool {
// The blocking signal handler that main() waits on.
out := make(chan bool)
Expand Down
3 changes: 3 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/knadh/koanf/providers/env"
"github.com/knadh/listmonk/internal/bounce"
"github.com/knadh/listmonk/internal/buflog"
"github.com/knadh/listmonk/internal/captcha"
"github.com/knadh/listmonk/internal/core"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
Expand Down Expand Up @@ -47,6 +48,7 @@ type App struct {
i18n *i18n.I18n
bounce *bounce.Manager
paginator *paginator.Paginator
captcha *captcha.Captcha
notifTpls *notifTpls
log *log.Logger
bufLog *buflog.BufLog
Expand Down Expand Up @@ -168,6 +170,7 @@ func main() {
messengers: make(map[string]messenger.Messenger),
log: lo,
bufLog: bufLog,
captcha: initCaptcha(),

paginator: paginator.New(paginator.Opt{
DefaultPerPage: 20,
Expand Down
20 changes: 19 additions & 1 deletion cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ type msgTpl struct {

type subFormTpl struct {
publicTpl
Lists []models.List
Lists []models.List
CaptchaKey string
}

var (
Expand Down Expand Up @@ -418,6 +419,10 @@ func handleSubscriptionFormPage(c echo.Context) error {
out.Title = app.i18n.T("public.sub")
out.Lists = lists

if app.constants.Security.EnableCaptcha {
out.CaptchaKey = app.constants.Security.CaptchaKey
}

return c.Render(http.StatusOK, "subscription-form", out)
}

Expand All @@ -433,6 +438,19 @@ func handleSubscriptionForm(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature"))
}

// Process CAPTCHA.
if app.constants.Security.EnableCaptcha {
err, ok := app.captcha.Verify(c.FormValue("h-captcha-response"))
if err != nil {
app.log.Printf("Captcha request failed: %v", err)
}

if !ok {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
}
}

hasOptin, err := processSubForm(c)
if err != nil {
e, ok := err.(*echo.HTTPError)
Expand Down
4 changes: 4 additions & 0 deletions cmd/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func handleGetSettings(c echo.Context) error {
}
s.UploadS3AwsSecretAccessKey = ""
s.SendgridKey = ""
s.SecurityCaptchaSecret = ""

return c.JSON(http.StatusOK, okResp{s})
}
Expand Down Expand Up @@ -158,6 +159,9 @@ func handleUpdateSettings(c echo.Context) error {
if set.SendgridKey == "" {
set.SendgridKey = cur.SendgridKey
}
if set.SecurityCaptchaSecret == "" {
set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret
}

// Domain blocklist.
doms := make([]string, 0)
Expand Down
1 change: 1 addition & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ var migList = []migFunc{
{"v2.1.0", migrations.V2_1_0},
{"v2.2.0", migrations.V2_2_0},
{"v2.3.0", migrations.V2_3_0},
{"v2.4.0", migrations.V2_4_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
<privacy-settings :form="form" :key="key" />
</b-tab-item><!-- privacy -->

<b-tab-item :label="$t('settings.security.name')">
<security-settings :form="form" :key="key" />
</b-tab-item><!-- security -->

<b-tab-item :label="$t('settings.media.title')">
<media-settings :form="form" :key="key" />
</b-tab-item><!-- media -->
Expand Down Expand Up @@ -66,6 +70,7 @@ import { mapState } from 'vuex';
import GeneralSettings from './settings/general.vue';
import PerformanceSettings from './settings/performance.vue';
import PrivacySettings from './settings/privacy.vue';
import SecuritySettings from './settings/security.vue';
import MediaSettings from './settings/media.vue';
import SmtpSettings from './settings/smtp.vue';
import BounceSettings from './settings/bounces.vue';
Expand All @@ -79,6 +84,7 @@ export default Vue.extend({
GeneralSettings,
PerformanceSettings,
PrivacySettings,
SecuritySettings,
MediaSettings,
SmtpSettings,
BounceSettings,
Expand Down Expand Up @@ -136,6 +142,10 @@ export default Vue.extend({
form['bounce.sendgrid_key'] = '';
}
if (form['security.captcha_secret'] === dummyPassword) {
form['security.captcha_secret'] = '';
}
for (let i = 0; i < form.messengers.length; i += 1) {
// If it's the dummy UI password placeholder, ignore it.
if (form.messengers[i].password === dummyPassword) {
Expand Down Expand Up @@ -203,6 +213,7 @@ export default Vue.extend({
d['upload.s3.aws_secret_access_key'] = dummyPassword;
}
d['bounce.sendgrid_key'] = dummyPassword;
d['security.captcha_secret'] = dummyPassword;
// Domain blocklist array to multi-line string.
d['privacy.domain_blocklist'] = d['privacy.domain_blocklist'].join('\n');
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/views/settings/security.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div class="items">
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.security.enableCaptcha')"
:message="$t('settings.security.enableCaptchaHelp')">
<b-switch v-model="data['security.enable_captcha']"
name="security.captcha" />
</b-field>
</div>
<div class="column is-8">
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
:message="$t('settings.security.captchaKeyHelp')">
<b-input v-model="data['security.captcha_key']" name="captcha_key"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
<b-input v-model="data['security.captcha_secret']" name="captcha_secret" type="password"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
</b-field>
</div>
</div>
</div>
</template>

<script>
import Vue from 'vue';
export default Vue.extend({
props: {
form: {
type: Object,
},
},
data() {
return {
data: this.form,
};
},
});
</script>
7 changes: 7 additions & 0 deletions i18n/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"public.errorFetchingLists": "S'ha produït un error en obtenir les llistes. Si us plau, torna-ho a provar.",
"public.errorProcessingRequest": "S'ha produït un error en processar la sol·licitud. Si us plau, torna-ho a provar.",
"public.errorTitle": "Error",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Aquesta funció no està disponible.",
"public.invalidLink": "Enllaç no vàlid",
"public.managePrefs": "Gestiona les preferències",
Expand Down Expand Up @@ -473,6 +474,12 @@
"settings.privacy.listUnsubHeaderHelp": "Inclou capçaleres de cancel·lació de subscripció que permetin als clients de correu electrònic permetre als usuaris donar-se de baixa amb un sol clic.",
"settings.privacy.name": "Privadesa",
"settings.restart": "Reinicia",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Capçaleres personalitzades",
"settings.smtp.customHeadersHelp": "Matriu opcional de capçaleres de correu electrònic per incloure en tots els missatges enviats des d'aquest servidor. p. ex.: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Habilitat",
Expand Down
7 changes: 7 additions & 0 deletions i18n/cs-cz.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"public.errorFetchingLists": "Chyba při načítání seznamů. Zopakujte pokus.",
"public.errorProcessingRequest": "Chyba při zpracování požadavku. Zopakujte pokus.",
"public.errorTitle": "Chyba",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Tato funkce není k dispozici.",
"public.invalidLink": "Neplatný odkaz",
"public.managePrefs": "Zpráva předvoleb",
Expand Down Expand Up @@ -473,6 +474,12 @@
"settings.privacy.listUnsubHeaderHelp": "Zahrnout záhlaví zrušení odběrů, která umožňují e-mailovým klientům, aby povolili uživatelům zrušit odběr jediným klepnutím.",
"settings.privacy.name": "Soukromí",
"settings.restart": "Restart",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Vlastní záhlaví",
"settings.smtp.customHeadersHelp": "Volitelné pole e-mailových záhlaví, která se mají zahrnout do všech zpráv odeslaných z tohoto serveru. Např.: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Povoleno",
Expand Down
7 changes: 7 additions & 0 deletions i18n/cy.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"public.errorFetchingLists": "Gwall wrth chwilio am y rhestrau. Rhowch gynnig arall arni.",
"public.errorProcessingRequest": "Gwall wrth brosesu'r cais. Rhowch gynnig arall arni.",
"public.errorTitle": "Gwall",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Nid yw'r nodwedd ar gael.",
"public.invalidLink": "Dolen annilys",
"public.managePrefs": "Rheoli dewisiadau",
Expand Down Expand Up @@ -473,6 +474,12 @@
"settings.privacy.listUnsubHeaderHelp": "Cynnwys penynnau dad-danysgrifio sy'n caniatáu i ddefnyddwyr dad-danysgrifio drwy glicio un botwm.",
"settings.privacy.name": "Preifatrwydd",
"settings.restart": "Ailgychwyn",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Penynnau personol",
"settings.smtp.customHeadersHelp": "Ystod eang o bennynau e-bost i'w cynnwys mewn negeseuon a anfonir gan y gweinydd hwn. ee: [{\"\"X-Custom\"\": \"\"gwerth\"\"}",
"settings.smtp.enabled": "Wedi galluogi",
Expand Down
7 changes: 7 additions & 0 deletions i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"public.errorFetchingLists": "Fehler beim Abrufen der Listen. Bitte probiere es nochmal.",
"public.errorProcessingRequest": "Fehler bei der Anfrage. Bitte probiere es nochmal.",
"public.errorTitle": "Fehler",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Dieses Feature ist nicht verfügbar",
"public.invalidLink": "Ungültiger Link",
"public.managePrefs": "Einstellungen verwalten",
Expand Down Expand Up @@ -473,6 +474,12 @@
"settings.privacy.listUnsubHeaderHelp": "Inkludiere Header zum einfachen Abmelden in den E-Mails. Erlaubt es, den E-Mail Clients der Nutzer eine \",Ein Klick\"-Abmeldung anzubieten.",
"settings.privacy.name": "Privatsphäre",
"settings.restart": "Neustarten",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Benutzerdefinierte Header",
"settings.smtp.customHeadersHelp": "(Optional) Array von benutzerdefinierten E-Mail Headern, welche in die Nachricht eingefügt werden sollen. Z.B.: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Aktiviert",
Expand Down
7 changes: 7 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"public.errorFetchingLists": "Error fetching lists. Please retry.",
"public.errorProcessingRequest": "Error processing request. Please retry.",
"public.errorTitle": "Error",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "That feature is not available.",
"public.invalidLink": "Invalid link",
"public.managePrefs": "Manage preferences",
Expand Down Expand Up @@ -473,6 +474,12 @@
"settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
"settings.privacy.name": "Privacy",
"settings.restart": "Restart",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Enabled",
Expand Down
7 changes: 7 additions & 0 deletions i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"public.errorFetchingLists": "Error obteniendo listas. Por favor intente nuevamente.",
"public.errorProcessingRequest": "Error al procesar la petición. Por favor intente nuevamente.",
"public.errorTitle": "Error",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Esta función no está disponible",
"public.invalidLink": "Enlace inválido",
"public.managePrefs": "Gestionar las preferencias",
Expand Down Expand Up @@ -474,6 +475,12 @@
"settings.privacy.listUnsubHeaderHelp": "Incluye los encabezados de darse de baja para habilitar a los clientes de correo para permitir a los usuarios darse de baja con un solo clic.",
"settings.privacy.name": "Privacidad",
"settings.restart": "Reiniciar",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Encabezados personalizados",
"settings.smtp.customHeadersHelp": "Lista de encabezados opcionales a incluir en todos los mensajes enviados desde este servidor. Por ejemplo {{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Habilitado",
Expand Down
7 changes: 7 additions & 0 deletions i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"public.errorFetchingLists": "Virhe noutaessa postituslistoja. Ole hyvä ja yritä uudestaan.",
"public.errorProcessingRequest": "Virhe käsitellessä pyyntöäsi. Ole hyvä ja yritä uudestaan.",
"public.errorTitle": "Tapahtui virhe",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Tämä ominaisuus ei ole saatavilla.",
"public.invalidLink": "Virheellinen linkki",
"public.managePrefs": "Manage preferences",
Expand Down Expand Up @@ -474,6 +475,12 @@
"settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
"settings.privacy.name": "Yksityisyys",
"settings.restart": "Restart",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Enabled",
Expand Down
7 changes: 7 additions & 0 deletions i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"public.errorFetchingLists": "Erreur lors de la récupération des listes. Veuillez réessayer.",
"public.errorProcessingRequest": "Erreur lors du traitement de la demande. Veuillez réessayer.",
"public.errorTitle": "Erreur",
"public.invalidCaptcha": "Invalid CAPTCHA.",
"public.invalidFeature": "Cette fonctionnalité n'est pas disponible.",
"public.invalidLink": "Lien invalide",
"public.managePrefs": "Gérer les préférences",
Expand Down Expand Up @@ -474,6 +475,12 @@
"settings.privacy.listUnsubHeaderHelp": "Inclure des en-têtes de désabonnement qui permettent aux utilisateurs de se désabonner en un seul clic depuis leur client de messagerie.",
"settings.privacy.name": "Vie privée",
"settings.restart": "Redémarrer",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",
"settings.security.name": "Security",
"settings.smtp.customHeaders": "En-têtes personnalisées",
"settings.smtp.customHeadersHelp": "Tableau facultatif d'en-têtes à inclure dans tous les e-mails envoyés depuis ce serveur. Par exemple : [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Activé",
Expand Down
Loading

0 comments on commit 8985e5c

Please sign in to comment.