feature: Implement <meta> tag fetcher from backends with allow-listed entries to prevent unwanted keys to pass
This commit is contained in:
@@ -5,15 +5,11 @@
|
|||||||
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
|
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<meta name="referrer" content="origin"/>
|
<meta name="referrer" content="origin"/>
|
||||||
{{ range $key, $value := .Meta }}
|
{{ range .Meta }}
|
||||||
{{ if eq $key "refresh"}}
|
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||||
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
|
|
||||||
{{else}}
|
|
||||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
|
||||||
{{end}}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ range .HeaderTags }}
|
{{ range .HeaderTags }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body id="top">
|
<body id="top">
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{{$theme := "forgejo-auto"}}
|
{{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ end }}
|
||||||
{{ if .Theme }}
|
|
||||||
{{$theme = .Theme}}
|
|
||||||
{{ end }}
|
|
||||||
<html lang="en-US" data-theme="{{ $theme }}">
|
<html lang="en-US" data-theme="{{ $theme }}">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
<title>{{ .Title }}</title>
|
<title>{{ .Title }}</title>
|
||||||
<meta name="referrer" content="origin">
|
<meta name="referrer" content="origin">
|
||||||
|
{{ range .Meta }}
|
||||||
{{ range $key, $value := .Meta }}
|
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||||
{{ if eq $key "refresh"}}
|
|
||||||
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
|
|
||||||
{{else}}
|
|
||||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
|
||||||
{{end}}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ range .HeaderTags }}
|
{{ range .HeaderTags }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
|
||||||
@@ -80,9 +72,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<noscript>
|
{{if .EndTags }}
|
||||||
{{ .Strings.Get "noscript" }}
|
<noscript>
|
||||||
</noscript>
|
{{ .Strings.Get "noscript_warning" }}
|
||||||
|
</noscript>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
|
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -287,6 +287,14 @@ rules:
|
|||||||
conditions:
|
conditions:
|
||||||
- '!(method == "HEAD" || method == "GET")'
|
- '!(method == "HEAD" || method == "GET")'
|
||||||
|
|
||||||
|
# Enable fetching OpenGraph and other tags from backend on these paths
|
||||||
|
- name: enable-meta-tags
|
||||||
|
action: context
|
||||||
|
settings:
|
||||||
|
context-set:
|
||||||
|
proxy-meta-tags: "true"
|
||||||
|
|
||||||
|
|
||||||
- name: plaintext-browser
|
- name: plaintext-browser
|
||||||
action: challenge
|
action: challenge
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ func (d *RequestData) NetworkPrefix() netip.Addr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
RequestOptBackendHost = "backend-host"
|
||||||
|
RequestOptCacheMetaTags = "proxy-meta-tags"
|
||||||
|
)
|
||||||
|
|
||||||
func (d *RequestData) SetOpt(n, v string) {
|
func (d *RequestData) SetOpt(n, v string) {
|
||||||
d.opts[n] = v
|
d.opts[n] = v
|
||||||
}
|
}
|
||||||
@@ -156,6 +161,31 @@ func (d *RequestData) GetOpt(n, def string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) GetOptBool(n string, def bool) bool {
|
||||||
|
v, ok := d.opts[n]
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "true", "t", "1", "yes", "yep", "y", "ok":
|
||||||
|
return true
|
||||||
|
case "false", "f", "0", "no", "nope", "n", "err":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) BackendHost() (http.Handler, string) {
|
||||||
|
host := d.r.Host
|
||||||
|
|
||||||
|
if opt := d.GetOpt(RequestOptBackendHost, ""); opt != "" && opt != host {
|
||||||
|
host = d.r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.State.GetBackend(host), host
|
||||||
|
}
|
||||||
|
|
||||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
var issuedChallenge string
|
var issuedChallenge string
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
|||||||
|
|
||||||
if params.Mode == "meta" {
|
if params.Mode == "meta" {
|
||||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||||
"Meta": map[string]string{
|
"Meta": []map[string]string{
|
||||||
"refresh": "0; url=" + uri.String(),
|
{
|
||||||
|
"http-equiv": "refresh",
|
||||||
|
"content": "0; url=" + uri.String(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+110
-2
@@ -8,9 +8,12 @@ import (
|
|||||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"golang.org/x/net/html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||||
@@ -35,6 +38,98 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
|||||||
return slog.With(args...)
|
return slog.With(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (state *State) fetchMetaTags(host string, backend http.Handler, r *http.Request) []html.Node {
|
||||||
|
uri := *r.URL
|
||||||
|
q := uri.Query()
|
||||||
|
for k := range q {
|
||||||
|
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||||
|
q.Del(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uri.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%s:%s", host, uri.String())
|
||||||
|
|
||||||
|
if v, ok := state.tagCache.Get(key); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
result := utils.FetchTags(backend, &uri, "meta")
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]html.Node, 0, len(result))
|
||||||
|
|
||||||
|
safeAttributes := []string{"name", "property", "content"}
|
||||||
|
for _, n := range result {
|
||||||
|
if n.Namespace != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Namespace != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if attr.Key == "name" {
|
||||||
|
name = attr.Val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attr.Key == "property" && name == "" {
|
||||||
|
name = attr.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
|
||||||
|
|
||||||
|
var keep bool
|
||||||
|
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
|
||||||
|
// social / OpenGraph tags
|
||||||
|
keep = true
|
||||||
|
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
|
||||||
|
// source tags
|
||||||
|
keep = true
|
||||||
|
} else if name == "forge" || strings.HasPrefix("forge:", name) {
|
||||||
|
// forge tags
|
||||||
|
keep = true
|
||||||
|
} else {
|
||||||
|
switch name {
|
||||||
|
// standard content tags
|
||||||
|
case "application-name", "author", "description", "keywords", "robots", "thumbnail":
|
||||||
|
keep = true
|
||||||
|
case "go-import", "go-source":
|
||||||
|
// golang tags
|
||||||
|
keep = true
|
||||||
|
case "apple-itunes-app":
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent other arbitrary arguments
|
||||||
|
if keep {
|
||||||
|
newNode := html.Node{
|
||||||
|
Type: html.ElementNode,
|
||||||
|
Data: n.Data,
|
||||||
|
}
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Namespace != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(safeAttributes, attr.Key) {
|
||||||
|
newNode.Attr = append(newNode.Attr, attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newNode.Attr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, newNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tagCache.Set(key, entries, time.Hour*6)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
host := r.Host
|
host := r.Host
|
||||||
|
|
||||||
@@ -46,6 +141,19 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBackend := func() http.Handler {
|
||||||
|
if opt := data.GetOpt(challenge.RequestOptBackendHost, ""); opt != "" && opt != host {
|
||||||
|
b := state.GetBackend(host)
|
||||||
|
if b == nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||||
|
// return empty backend
|
||||||
|
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
lg := state.Logger(r)
|
lg := state.Logger(r)
|
||||||
|
|
||||||
cleanupRequest := func(r *http.Request, fromChallenge bool) {
|
cleanupRequest := func(r *http.Request, fromChallenge bool) {
|
||||||
@@ -81,7 +189,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, rule := range state.rules {
|
for _, rule := range state.rules {
|
||||||
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
||||||
cleanupRequest(r, true)
|
cleanupRequest(r, true)
|
||||||
return backend
|
return getBackend()
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||||
@@ -103,7 +211,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.Header.Set("X-Away-Action", "PASS")
|
r.Header.Set("X-Away-Action", "PASS")
|
||||||
|
|
||||||
cleanupRequest(r, false)
|
cleanupRequest(r, false)
|
||||||
return backend
|
return getBackend()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.gammaspectra.live/git/go-away/utils"
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
"github.com/yl2chen/cidranger"
|
"github.com/yl2chen/cidranger"
|
||||||
|
"golang.org/x/net/html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -43,6 +44,8 @@ type State struct {
|
|||||||
|
|
||||||
close chan struct{}
|
close chan struct{}
|
||||||
|
|
||||||
|
tagCache *utils.DecayMap[string, []html.Node]
|
||||||
|
|
||||||
Mux *http.ServeMux
|
Mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,5 +234,20 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.tagCache = utils.NewDecayMap[string, []html.Node]()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute * 37)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
state.tagCache.Decay()
|
||||||
|
case <-state.close:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-1
@@ -36,7 +36,14 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initTemplate(name, data string) error {
|
func initTemplate(name, data string) error {
|
||||||
tpl := template.New(name)
|
tpl := template.New(name).Funcs(template.FuncMap{
|
||||||
|
"attr": func(s string) template.HTMLAttr {
|
||||||
|
return template.HTMLAttr(s)
|
||||||
|
},
|
||||||
|
"safe": func(s string) template.HTML {
|
||||||
|
return template.HTML(s)
|
||||||
|
},
|
||||||
|
})
|
||||||
_, err := tpl.Parse(data)
|
_, err := tpl.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -68,6 +75,22 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
|
|||||||
input["Title"] = state.Options().Strings.Get("title_challenge")
|
input["Title"] = state.Options().Strings.Get("title_challenge")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
|
||||||
|
backend, host := data.BackendHost()
|
||||||
|
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
|
||||||
|
tagMap, _ := input["Meta"].([]map[string]string)
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||||
|
for _, v := range tag.Attr {
|
||||||
|
tagAttrs[v.Key] = v.Val
|
||||||
|
}
|
||||||
|
tagMap = append(tagMap, tagAttrs)
|
||||||
|
}
|
||||||
|
input["Meta"] = tagMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||||
@@ -103,6 +126,22 @@ func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int
|
|||||||
input[k] = v
|
input[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
|
||||||
|
backend, host := data.BackendHost()
|
||||||
|
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
|
||||||
|
tagMap, _ := input["Meta"].([]map[string]string)
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||||
|
for _, v := range tag.Attr {
|
||||||
|
tagAttrs[v.Key] = v.Val
|
||||||
|
}
|
||||||
|
tagMap = append(tagMap, tagAttrs)
|
||||||
|
}
|
||||||
|
input["Meta"] = tagMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
|
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
// nested errors!
|
// nested errors!
|
||||||
|
|||||||
+3
-3
@@ -10,17 +10,17 @@ func zilch[T any]() T {
|
|||||||
return zero
|
return zero
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecayMap[K, V comparable] struct {
|
type DecayMap[K comparable, V any] struct {
|
||||||
data map[K]DecayMapEntry[V]
|
data map[K]DecayMapEntry[V]
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecayMapEntry[V comparable] struct {
|
type DecayMapEntry[V any] struct {
|
||||||
Value V
|
Value V
|
||||||
expiry time.Time
|
expiry time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDecayMap[K, V comparable]() *DecayMap[K, V] {
|
func NewDecayMap[K comparable, V any]() *DecayMap[K, V] {
|
||||||
return &DecayMap[K, V]{
|
return &DecayMap[K, V]{
|
||||||
data: make(map[K]DecayMapEntry[V]),
|
data: make(map[K]DecayMapEntry[V]),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FetchTags(backend http.Handler, uri *url.URL, kind string) (result []html.Node) {
|
||||||
|
writer := httptest.NewRecorder()
|
||||||
|
backend.ServeHTTP(writer, &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: uri,
|
||||||
|
Header: http.Header{
|
||||||
|
"User-Agent": []string{"Mozilla 5.0 (compatible; go-away/1.0 fetch-tags) TwitterBot/1.0"},
|
||||||
|
"Accept": []string{"text/html,application/xhtml+xml"},
|
||||||
|
},
|
||||||
|
Close: true,
|
||||||
|
})
|
||||||
|
response := writer.Result()
|
||||||
|
if response == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "text/html" && contentType != "application/xhtml+xml" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: handle non UTF-8 documents
|
||||||
|
node, err := html.ParseWithOptions(response.Body, html.ParseOptionEnableScripting(false))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := range node.Descendants() {
|
||||||
|
if n.Type == html.ElementNode && n.Data == kind {
|
||||||
|
result = append(result, html.Node{
|
||||||
|
Type: n.Type,
|
||||||
|
DataAtom: n.DataAtom,
|
||||||
|
Data: n.Data,
|
||||||
|
Namespace: n.Namespace,
|
||||||
|
Attr: n.Attr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user