Add cross-request forgery detection
I use the ten first digits of the cookie’s hash, that i believe it is not a problem, has the advantage of not expiring until the user logs out, and using a per user session token is explicitly allowed by OWASP[0]. [0]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern
This commit is contained in:
parent
7a439a40cc
commit
917db31227
|
@ -16,6 +16,7 @@ select user_id
|
|||
, name
|
||||
, role
|
||||
, lang_tag
|
||||
, left(cookie, 10) as csrf_token
|
||||
from auth."user"
|
||||
where email = current_user_email()
|
||||
and cookie = current_user_cookie()
|
||||
|
@ -27,6 +28,7 @@ select 0
|
|||
, ''
|
||||
, 'guest'::name
|
||||
, 'und'
|
||||
, ''
|
||||
where not exists (
|
||||
select 1
|
||||
from auth."user"
|
||||
|
|
|
@ -142,6 +142,10 @@ func CompanyTaxDetailsHandler() http.Handler {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if ok := page.DetailsForm.Validate(r.Context(), conn); ok {
|
||||
form := page.DetailsForm
|
||||
conn.MustExec(r.Context(), "update company set business_name = $1, vatin = ($11 || $2)::vatin, trade_name = $3, phone = parse_packed_phone_number($4, $11), email = $5, web = $6, address = $7, city = $8, province = $9, postal_code = $10, country_code = $11, currency_code = $12 where company_id = $13", form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Currency, company.Id)
|
||||
|
@ -243,6 +247,10 @@ func CompanyTaxHandler() http.Handler {
|
|||
conn := getConn(r)
|
||||
company := mustGetCompany(r)
|
||||
if taxId, err := strconv.Atoi(param); err == nil {
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
|
||||
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
|
||||
} else {
|
||||
|
@ -252,6 +260,10 @@ func CompanyTaxHandler() http.Handler {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if form.Validate() {
|
||||
conn.MustExec(r.Context(), "insert into tax (company_id, name, rate) values ($1, $2, $3 / 100::decimal)", company.Id, form.Name, form.Rate.Integer())
|
||||
http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther)
|
||||
|
|
|
@ -27,6 +27,10 @@ func ContactsHandler() http.Handler {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if form.Validate(r.Context(), conn) {
|
||||
conn.MustExec(r.Context(), "insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, province, city, postal_code, country_code) values ($1, $2, ($12 || $3)::vatin, $4, parse_packed_phone_number($5, $12), $6, $7, $8, $9, $10, $11, $12)", company.Id, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country)
|
||||
http.Redirect(w, r, "/company/"+company.Slug+"/contacts", http.StatusSeeOther)
|
||||
|
|
20
pkg/login.go
20
pkg/login.go
|
@ -17,6 +17,7 @@ const (
|
|||
ContextConnKey = "numerus-database"
|
||||
sessionCookie = "numerus-session"
|
||||
defaultRole = "guest"
|
||||
csrfTokenField = "csfrToken"
|
||||
)
|
||||
|
||||
type loginForm struct {
|
||||
|
@ -105,6 +106,10 @@ func LoginHandler() http.Handler {
|
|||
|
||||
func LogoutHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn := getConn(r)
|
||||
conn.MustExec(r.Context(), "select logout()")
|
||||
http.SetCookie(w, createSessionCookie("", -24*time.Hour))
|
||||
|
@ -140,6 +145,7 @@ type AppUser struct {
|
|||
LoggedIn bool
|
||||
Role string
|
||||
Language language.Tag
|
||||
CsrfToken string
|
||||
}
|
||||
|
||||
func CheckLogin(db *Db, next http.Handler) http.Handler {
|
||||
|
@ -158,9 +164,9 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
|
|||
LoggedIn: false,
|
||||
Role: defaultRole,
|
||||
}
|
||||
row := conn.QueryRow(ctx, "select coalesce(email, ''), role, lang_tag from user_profile")
|
||||
row := conn.QueryRow(ctx, "select coalesce(email, ''), role, lang_tag, csrf_token from user_profile")
|
||||
var langTag string
|
||||
if err := row.Scan(&user.Email, &user.Role, &langTag); err != nil {
|
||||
if err := row.Scan(&user.Email, &user.Role, &langTag, &user.CsrfToken); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
user.LoggedIn = user.Email != ""
|
||||
|
@ -171,6 +177,16 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func verifyCsrfTokenValid(r *http.Request) error {
|
||||
user := getUser(r)
|
||||
token := r.FormValue(csrfTokenField)
|
||||
if user.CsrfToken == token {
|
||||
return nil
|
||||
}
|
||||
locale := getLocale(r)
|
||||
return errors.New(locale.Get("Cross-site request forgery detected."))
|
||||
}
|
||||
|
||||
func getUser(r *http.Request) *AppUser {
|
||||
return r.Context().Value(ContextUserKey).(*AppUser)
|
||||
}
|
||||
|
|
|
@ -104,6 +104,10 @@ func ProfileHandler() http.Handler {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if ok := form.Validate(); ok {
|
||||
//goland:noinspection SqlWithoutWhere
|
||||
cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name, form.Email, form.Language)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
|
@ -13,6 +14,7 @@ func templateFile(name string) string {
|
|||
func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) {
|
||||
locale := getLocale(r)
|
||||
company := getCompany(r)
|
||||
user := getUser(r)
|
||||
t := template.New(filename)
|
||||
t.Funcs(template.FuncMap{
|
||||
"gettext": locale.Get,
|
||||
|
@ -26,6 +28,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
}
|
||||
return "/company/" + company.Slug + uri
|
||||
},
|
||||
"csrfToken": func() template.HTML {
|
||||
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
|
||||
},
|
||||
"addInputAttr": func(attr string, field *InputField) *InputField {
|
||||
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
|
||||
return field
|
||||
|
|
|
@ -5,7 +5,7 @@ reset client_min_messages;
|
|||
|
||||
begin;
|
||||
|
||||
select plan(47);
|
||||
select plan(53);
|
||||
|
||||
set search_path to numerus, auth, public;
|
||||
|
||||
|
@ -50,6 +50,13 @@ select column_privs_are('user_profile', 'lang_tag', 'invoicer', array['SELECT',
|
|||
select column_privs_are('user_profile', 'lang_tag', 'admin', array['SELECT', 'UPDATE']);
|
||||
select column_privs_are('user_profile', 'lang_tag', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', 'csrf_token');
|
||||
select col_type_is('user_profile', 'csrf_token', 'text');
|
||||
select column_privs_are('user_profile', 'csrf_token', 'guest', array ['SELECT']);
|
||||
select column_privs_are('user_profile', 'csrf_token', 'invoicer', array['SELECT']);
|
||||
select column_privs_are('user_profile', 'csrf_token', 'admin', array['SELECT']);
|
||||
select column_privs_are('user_profile', 'csrf_token', 'authenticator', array[]::text[]);
|
||||
|
||||
|
||||
set client_min_messages to warning;
|
||||
truncate auth."user" cascade;
|
||||
|
@ -62,14 +69,14 @@ values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4b
|
|||
;
|
||||
|
||||
prepare profile as
|
||||
select user_id, email, name, role, lang_tag
|
||||
select user_id, email, name, role, lang_tag, csrf_token
|
||||
from user_profile;
|
||||
|
||||
select set_config('request.user.cookie', '', false);
|
||||
|
||||
select results_eq(
|
||||
'profile',
|
||||
$$ values (0, null::email, '', 'guest'::name, 'und') $$,
|
||||
$$ values (0, null::email, '', 'guest'::name, 'und', '') $$,
|
||||
'Should be set up with the guest user when no user logged in yet.'
|
||||
);
|
||||
|
||||
|
@ -77,7 +84,7 @@ select set_cookie( '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tand
|
|||
|
||||
select results_eq(
|
||||
'profile',
|
||||
$$ values (1, 'demo@tandem.blog'::email, 'Demo', 'invoicer'::name, 'ca') $$,
|
||||
$$ values (1, 'demo@tandem.blog'::email, 'Demo', 'invoicer'::name, 'ca', '44facbb30d') $$,
|
||||
'Should only see the profile of the first user'
|
||||
);
|
||||
|
||||
|
@ -104,7 +111,7 @@ select throws_ok(
|
|||
|
||||
select results_eq(
|
||||
'profile',
|
||||
$$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'invoicer'::name, 'es') $$,
|
||||
$$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'invoicer'::name, 'es', '44facbb30d') $$,
|
||||
'Should see the changed profile of the first user'
|
||||
);
|
||||
|
||||
|
@ -114,7 +121,7 @@ select set_cookie( '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tan
|
|||
|
||||
select results_eq(
|
||||
'profile',
|
||||
$$ values (5, 'admin@tandem.blog'::email, 'Admin', 'admin'::name, 'es') $$,
|
||||
$$ values (5, 'admin@tandem.blog'::email, 'Admin', 'admin'::name, 'es', '12af4c88b5') $$,
|
||||
'Should only see the profile of the second user'
|
||||
);
|
||||
|
||||
|
@ -141,7 +148,7 @@ select throws_ok(
|
|||
|
||||
select results_eq(
|
||||
'profile',
|
||||
$$ values (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'admin'::name, 'ca') $$,
|
||||
$$ values (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'admin'::name, 'ca', '12af4c88b5') $$,
|
||||
'Should see the changed profile of the first user'
|
||||
);
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ select
|
|||
, name
|
||||
, role
|
||||
, lang_tag
|
||||
, csrf_token
|
||||
from numerus.user_profile
|
||||
where false;
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
</li>
|
||||
<li role="presentation">
|
||||
<form method="POST" action="/logout">
|
||||
{{ csrfToken }}
|
||||
<button type="submit" role="menuitem">
|
||||
<i class="ri-logout-circle-line"></i>
|
||||
{{( pgettext "Logout" "action" )}}
|
||||
|
|
|
@ -3,9 +3,11 @@
|
|||
{{- end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.contactForm*/ -}}
|
||||
<section class="dialog-content">
|
||||
<h2>{{(pgettext "New Contact" "title")}}</h2>
|
||||
<form method="POST" action="{{ companyURI "/contacts" }}">
|
||||
{{ csrfToken }}
|
||||
{{ template "input-field" .BusinessName | addInputAttr "autofocus" }}
|
||||
{{ template "input-field" .VATIN }}
|
||||
{{ template "input-field" .TradeName }}
|
||||
|
|
|
@ -3,9 +3,11 @@
|
|||
{{- end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.profileForm*/ -}}
|
||||
<section class="dialog-content">
|
||||
<h2>{{(pgettext "User Settings" "title")}}</h2>
|
||||
<form method="POST">
|
||||
{{ csrfToken }}
|
||||
<fieldset class="full-width">
|
||||
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
|
||||
|
||||
|
|
|
@ -5,8 +5,10 @@
|
|||
{{ define "content" }}
|
||||
<section class="dialog-content">
|
||||
<h2>{{(pgettext "Tax Details" "title")}}</h2>
|
||||
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.TaxDetailsPage*/ -}}
|
||||
{{ with .DetailsForm }}
|
||||
<form id="details" method="POST">
|
||||
{{ csrfToken }}
|
||||
{{ template "input-field" .BusinessName }}
|
||||
{{ template "input-field" .VATIN }}
|
||||
{{ template "input-field" .TradeName }}
|
||||
|
@ -28,6 +30,7 @@
|
|||
{{ end }}
|
||||
|
||||
<form id="newtax" method="POST" action="{{ companyURI "/tax" }}">
|
||||
{{ csrfToken }}
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
|
@ -49,6 +52,7 @@
|
|||
<td>{{ .Rate }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ companyURI "/tax"}}/{{ .Id }}">
|
||||
{{ csrfToken }}
|
||||
<input type="hidden" name="_method" value="DELETE"/>
|
||||
<button class="icon" aria-label="{{( gettext "Delete tax" )}}" type="submit"><i
|
||||
class="ri-delete-back-2-line"></i></button>
|
||||
|
|
Loading…
Reference in New Issue