Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove bluemonday and fix double-escaping #184

Merged
merged 2 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 0 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ require (
github.com/go-pkgz/repeater v1.1.3
github.com/go-pkgz/rest v1.18.2
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/microcosm-cc/bluemonday v1.0.26
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
github.com/stretchr/testify v1.8.4
go.etcd.io/bbolt v1.3.8
Expand All @@ -21,12 +20,10 @@ require (
require (
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.1.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
Expand All @@ -43,7 +40,6 @@ require (
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -61,8 +59,6 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
Expand All @@ -82,8 +78,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
Expand Down
21 changes: 12 additions & 9 deletions provider/custom_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package provider

import (
"context"
"encoding/json"
"fmt"
"html/template"
"net"
Expand All @@ -12,7 +13,6 @@ import (
"time"

goauth2 "github.com/go-oauth2/oauth2/v4/server"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/oauth2"

"github.com/go-pkgz/auth/avatar"
Expand Down Expand Up @@ -160,16 +160,19 @@ func (c *CustomServer) handleUserInfo(w http.ResponseWriter, r *http.Request) {
}
userID := ti.GetUserID()

p := bluemonday.UGCPolicy()
ava := p.Sanitize(fmt.Sprintf(c.URL+"/avatar?user=%s", userID))
res := fmt.Sprintf(`{
"id": "%s",
"name":"%s",
"picture":"%s"
}`, userID, userID, ava)
user := token.User{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understnd how json marshal prevents injection of userID here. Even if it does somehow, it is missing a test demonstrating it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn't prevent html tags - it prevents backslashes and quotes (backslashes weren't caught with the old approach, so any ID ending with a backslash would break the JSON syntax)

the point is that HTML tags don't need to be escaped here: none of this stuff ends up as HTML (the picture might be rendered in a src attribute, but then it's up to the consumer to escape it at that point. It could just as easily be passed in to a fetch or similar, which would need different escaping semantics, so escaping it here is the wrong location.

for tests, this is covered by the existing tests, but a new one could be added with a backslash in the ID to prevent future regressions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds scary and means we are introducing a real attack vector to all the existing users who will have to handle potential injection via avatar's URL on some level. Avatars are here for only one reason - displaying them on web pages, and it is our responsibility to prevent possible attacks here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a possible option which preserves the "safety net" for users who forget to escape things in HTML themselves, without compromising functionality for users who do the right thing, would be to URL-encode the value (excluding / but being sure to include ", ', <, and >). This will prevent any injection attacks if rendered as a raw string into HTML, without breaking use-cases where the value is used in non-html-source ways (e.g. passing to a .src attribute via javascript, or using fetch, or using a server-side request for the image)

I'd personally argue that this safety net isn't necessary, as users who forget to escape the value will almost certainly have plenty of other vulnerabilities in their own code anyway, but it's available as an option which maintains correctness.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

considering this further: since the user ID is being passed in as a query parameter in the avatar URL, it should be URL encoded anyway just for correctness. And since c.URL is controlled by the developer, that part of the URL does not carry any risk.

I'll add URL encoding for the userID parameter in this URL (which was missing before) to make it more correct, which will have a side-effect of making it safe even for users who forget to escape the string when injecting into raw HTML. I'll also add a test for this situation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added url.QueryEscape around the userID and added a test to confirm it handles the important special characters. Note that this fixes an existing issue where user IDs containing & would be interpreted incorrectly in this URL (though I don't think it was possible to use that fact for a full exploit; just breakage)

ID: userID,
Name: userID,
Picture: fmt.Sprintf(c.URL+"/avatar?user=%s", url.QueryEscape(userID)),
}
res, err := json.Marshal(user)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write([]byte(res)); err != nil {
if _, err := w.Write(res); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
Expand Down
33 changes: 26 additions & 7 deletions provider/custom_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ func TestCustomProvider(t *testing.T) {
L: logger.Std,
}

var loginUsername string
var capturedUser token.User

sopts := CustomServerOpt{
URL: "http://127.0.0.1:9096",
L: logger.Std,
Expand All @@ -61,7 +64,7 @@ func TestCustomProvider(t *testing.T) {
jar.SetCookies(u, r.Cookies())

form := url.Values{}
form.Add("username", "admin")
form.Add("username", loginUsername)
form.Add("password", "pwd1234")

req, err := http.NewRequest("POST", "", strings.NewReader(form.Encode()))
Expand All @@ -87,9 +90,7 @@ func TestCustomProvider(t *testing.T) {
claims, err := params.JwtService.Parse(resp.Cookies()[0].Value)
assert.NoError(t, err)

assert.Equal(t, token.User{Name: "admin", ID: "admin",
Picture: "http://127.0.0.1:9096/avatar?user=admin", IP: ""}, *claims.User)

capturedUser = *claims.User
},
}

Expand Down Expand Up @@ -120,13 +121,16 @@ func TestCustomProvider(t *testing.T) {
client := &http.Client{Jar: jar, Timeout: time.Second * 10}

// check non-admin, permanent
loginUsername = "admin"
resp, err := client.Get("http://127.0.0.1:8080/auth/customprov/login?site=my-test-site")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
t.Logf("resp %s", string(body))
t.Logf("headers: %+v", resp.Header)
assert.Equal(t, token.User{Name: "admin", ID: "admin",
Picture: "http://127.0.0.1:9096/avatar?user=admin", IP: ""}, capturedUser)

// check avatar
resp, err = client.Get("http://127.0.0.1:9096/avatar?user=dev_user")
Expand All @@ -137,6 +141,18 @@ func TestCustomProvider(t *testing.T) {
assert.Equal(t, 960, len(body))
t.Logf("headers: %+v", resp.Header)

// check malicious user ID
loginUsername = "attack"
resp, err = client.Get("http://127.0.0.1:8080/auth/customprov/login?site=my-test-site")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
t.Logf("resp %s", string(body))
t.Logf("headers: %+v", resp.Header)
// user ID in picture URL is encoded
assert.Equal(t, "http://127.0.0.1:9096/avatar?user=none%26attack%3Dvalue%22%3E%3Cscript%3Enasty+stuff%3C%2Fscript%3E", capturedUser.Picture)

// check default login page
prov.LoginPageHandler = nil
resp, err = client.Get("http://127.0.0.1:8080/auth/customprov/login?site=my-test-site")
Expand Down Expand Up @@ -196,10 +212,13 @@ func initGoauth2Srv(t *testing.T) *goauth2.Server {
if r.ParseForm() != nil {
return "", fmt.Errorf("no username and password in request")
}
if r.Form.Get("username") != "admin" || r.Form.Get("password") != "pwd1234" {
return "", fmt.Errorf("wrong creds")
if r.Form.Get("username") == "admin" && r.Form.Get("password") == "pwd1234" {
return "admin", nil
}
if r.Form.Get("username") == "attack" && r.Form.Get("password") == "pwd1234" {
return "none&attack=value\"><script>nasty stuff</script>", nil
}
return "admin", nil
return "", fmt.Errorf("wrong creds")
})

srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
Expand Down
23 changes: 7 additions & 16 deletions provider/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (

"github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/microcosm-cc/bluemonday"

"github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/auth/logger"
Expand Down Expand Up @@ -134,9 +133,7 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
// GET /login?site=site&user=name&address=someone@example.com
func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request) {

user, address := r.URL.Query().Get("user"), r.URL.Query().Get("address")
user = e.sanitize(user)
address = e.sanitize(address)
user, address, site := r.URL.Query().Get("user"), r.URL.Query().Get("address"), r.URL.Query().Get("site")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one is unclear to me either. The PR comment says "bluemonday as it is not necessary (any tags entered are encoded by the template library anyway, so there is no risk of injection)" but this method is a handler, and nothing prevents bad actors to pass anything in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the sanitize'd values are passed to 2 places:

  • a handshake (which doesn't need them to be escaped or sanitised because it just handles them as a string)
  • an email template builder (which does its own escaping)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paskal - can you take a look pls? To me, it looks like the Sender called after html/template was applied, and it can be safe to avoid sanitizing here. However, as far as I recall, this code was added to address some potential vulnerabilities, and I don't recall many details about it anymore.

This sanitization was added by me on 06/03/22 "sanitize both user and address in verified provider". Do you recall if it was the result of some finding in remark42 around the same time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you decide double escaping is needed here (though I can't imagine why that would be), it can be achieved entirely though template.HTMLEscapeString without bluemonday being involved.

But note that if double escaping is required for some reason, that means there is an attack vector in the existing code because the free-valued site parameter did not go through the extra process (so was only escaped once).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a little digging in to how these checks came about and found your comment @umputun here:

#119 (comment)

That explains why you were previously vulnerable to this attack, but are now double-encoding. The text -> html template change was the only required change in the end - it made the rest of the changes obsolete, and this PR effectively undoes those parts.


if user == "" || address == "" {
rest.SendErrorJSON(w, r, e.L, http.StatusBadRequest, fmt.Errorf("wrong request"), "can't get user and address")
Expand All @@ -150,7 +147,7 @@ func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request)
},
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
StandardClaims: jwt.StandardClaims{
Audience: e.sanitize(r.URL.Query().Get("site")),
Audience: site,
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
Issuer: e.Issuer,
Expand Down Expand Up @@ -179,10 +176,10 @@ func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request)
Token string
Site string
}{
User: user,
Address: address,
User: trim(user),
Address: trim(address),
Token: tkn,
Site: r.URL.Query().Get("site"),
Site: site,
}
buf := bytes.Buffer{}
if err = emailTmpl.Execute(&buf, tmplData); err != nil {
Expand Down Expand Up @@ -212,14 +209,8 @@ Confirmation for {{.User}} {{.Address}}, site {{.Site}}
Token: {{.Token}}
`

func (e VerifyHandler) sanitize(inp string) string {
p := bluemonday.UGCPolicy()
res := p.Sanitize(inp)
res = template.HTMLEscapeString(res)
res = strings.ReplaceAll(res, "&amp;", "&")
res = strings.ReplaceAll(res, "&#34;", "\"")
res = strings.ReplaceAll(res, "&#39;", "'")
res = strings.ReplaceAll(res, "\n", "")
func trim(inp string) string {
res := strings.ReplaceAll(inp, "\n", "")
res = strings.TrimSpace(res)
if len(res) > 128 {
return res[:128]
Expand Down
15 changes: 9 additions & 6 deletions provider/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -59,7 +60,7 @@ func TestVerifyHandler_LoginSendConfirm(t *testing.T) {
assert.Equal(t, "test", e.Name())
}

func TestVerifyHandler_LoginSendConfirmRejected(t *testing.T) {
func TestVerifyHandler_LoginSendConfirmEscapesBadInput(t *testing.T) {

emailer := mockSender{}
e := VerifyHandler{
Expand All @@ -77,20 +78,22 @@ func TestVerifyHandler_LoginSendConfirmRejected(t *testing.T) {

handler := http.HandlerFunc(e.LoginHandler)
rr := httptest.NewRecorder()
badUser := "%3C%21DOCTYPE%20html%3E%20%0A%3Chtml%3E%20%0A%3Chead%3E%0A%3Cmeta%20name%3D%22viewport%22%20content%3D%22width%3Ddevice-width%2C%20initial-scale%3D1%22%3E%0A%3Ctitle%3E%20Login%20Page%20%3C%2Ftitle%3E%0A%3Cstyle%3E%20%0ABody%20%7B%0A%20%20font-family%3A%20Calibri%2C%20Helvetica%2C%20sans-serif%3B%0A%20%20background-color%3A%20pink%3B%0A%7D%0Abutton%20%7B%20%0A%20%20%20%20%20%20%20background-color%3A%20%234CAF50%3B%20%0A%20%20%20%20%20%20%20width%3A%20100%25%3B%0A%20%20%20%20%20%20%20%20color%3A%20orange%3B%20%0A%20%20%20%20%20%20%20%20padding%3A%2015px%3B%20%0A%20%20%20%20%20%20%20%20margin%3A%2010px%200px%3B%20%0A%20%20%20%20%20%20%20%20border%3A%20none%3B%20%0A%20%20%20%20%20%20%20%20cursor%3A%20pointer%3B%20%0A%20%20%20%20%20%20%20%20%20%7D%20%0A%20form%20%7B%20%0A%20%20%20%20%20%20%20%20border%3A%203px%20solid%20%23f1f1f1%3B%20%0A%20%20%20%20%7D%20%0A%20input%5Btype%3Dtext%5D%2C%20input%5Btype%3Dpassword%5D%20%7B%20%0A%20%20%20%20%20%20%20%20width%3A%20100%25%3B%20%0A%20%20%20%20%20%20%20%20margin%3A%208px%200%3B%0A%20%20%20%20%20%20%20%20padding%3A%2012px%2020px%3B%20%0A%20%20%20%20%20%20%20%20display%3A%20inline-block%3B%20%0A%20%20%20%20%20%20%20%20border%3A%202px%20solid%20green%3B%20%0A%20%20%20%20%20%20%20%20box-sizing%3A%20border-box%3B%20%0A%20%20%20%20%7D%0A%20button%3Ahover%20%7B%20%0A%20%20%20%20%20%20%20%20opacity%3A%200.7%3B%20%0A%20%20%20%20%7D%20%0A%20%20.cancelbtn%20%7B%20%0A%20%20%20%20%20%20%20%20width%3A%20auto%3B%20%0A%20%20%20%20%20%20%20%20padding%3A%2010px%2018px%3B%0A%20%20%20%20%20%20%20%20margin%3A%2010px%205px%3B%0A%20%20%20%20%7D%20%0A%20%20%20%20%20%20%0A%20%20%20%0A%20.container%20%7B%20%0A%20%20%20%20%20%20%20%20padding%3A%2025px%3B%20%0A%20%20%20%20%20%20%20%20background-color%3A%20lightblue%3B%0A%20%20%20%20%7D%20%0A%3C%2Fstyle%3E%20%0A%3C%2Fhead%3E%20%20%0A%3Cbody%3E%20%20%0A%20%20%20%20%3Ccenter%3E%20%3Ch1%3E%20Student%20Login%20Form%20%3C%2Fh1%3E%20%3C%2Fcenter%3E%20%0A%20%20%20%20%3Cform%3E%0A%20%20%20%20%20%20%20%20%3Cdiv%20class%3D%22container%22%3E%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Clabel%3EUsername%20%3A%20%3C%2Flabel%3E%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cinput%20type%3D%22text%22%20placeholder%3D%22Enter%20Username%22%20name%3D%22username%22%20required%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Clabel%3EPassword%20%3A%20%3C%2Flabel%3E%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cinput%20type%3D%22password%22%20placeholder%3D%22Enter%20Password%22%20name%3D%22password%22%20required%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cbutton%20type%3D%22submit%22%3ELogin%3C%2Fbutton%3E%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cinput%20type%3D%22checkbox%22%20checked%3D%22checked%22%3E%20Remember%20me%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cbutton%20type%3D%22button%22%20class%3D%22cancelbtn%22%3E%20Cancel%3C%2Fbutton%3E%20%0A%20%20%20%20%20%20%20%20%20%20%20%20Forgot%20%3Ca%20href%3D%22%23%22%3E%20password%3F%20%3C%2Fa%3E%20%0A%20%20%20%20%20%20%20%20%3C%2Fdiv%3E%20%0A%20%20%20%20%3C%2Fform%3E%20%20%20%0A%3C%2Fbody%3E%20%20%20%0A%3C%2Fhtml%3E%0A%0A%20%0A"
req, err := http.NewRequest("GET", "/login?address=blah@user.com&user="+badUser+"&site=remark42", http.NoBody)
badData := "<html><script>nasty stuff</script>&lt;escaped&gt;</html>"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get why this test changed this way and the user is url-escaped as a part of the test. It looks like you don't expect badUser to be passed in, but there is nothing preventing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mostly changed this to make it easier to reason about what's going on. The old test value was very long, and had its own hard-coded URL encoding, so to keep the test simple I replaced it with a shorter demonstration of the attack it's trying to prevent, and used the built-in function to automatically URL-encode it.

I renamed it from badUser to badData because I added it to the site parameter as well (which previously wasn't being tested for how it handles malicious data)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am almost sure this long and ugly input resulted from some vulnerability report we tried to replicate and address. I have asked @paskal to check it out and cross-reference, I will take a closer look too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous "badUser" input is nothing more than a URL encoded copy of a phishing message: if it is allowed to render raw, the user sees the phishing content before the real content of the message, and is encouraged to enter their password into an input field (in a real attack this would presumably submit somewhere).

As such, it's more of a demonstration of how this vulnerability could be used in a meaningful attack, than a carefully crafted string which triggers a vulnerability in itself. For the purpose of a test, I'd argue that keeping things simple is better. The much shorter input I swapped it with still tests the same attack vector, just without a full demonstration of the impact.

req, err := http.NewRequest("GET", "/login?address=blah@user.com&user="+url.QueryEscape(badData)+"&site="+url.QueryEscape(badData), http.NoBody)
require.NoError(t, err)
handler.ServeHTTP(rr, req)
assert.Equal(t, 200, rr.Code)
assert.Equal(t, "blah@user.com", emailer.to)
assert.Contains(t, emailer.text, "Password : blah@user.com remark42 token:")
expectedEscaped := "&lt;html&gt;&lt;script&gt;nasty stuff&lt;/script&gt;&amp;lt;escaped&amp;gt;&lt;/html&gt;"
assert.Contains(t, emailer.text, expectedEscaped+" blah@user.com "+expectedEscaped+" token:")

tknStr := strings.Split(emailer.text, " token:")[1]
tkn, err := e.TokenService.Parse(tknStr)
assert.NoError(t, err)
t.Logf("%s %+v", tknStr, tkn)
assert.Equal(t, "&lt;h1&gt; Student Login Form &lt;/h1&gt; &lt;div&gt; Username : Password : ::blah@user.com", tkn.Handshake.ID)
assert.Equal(t, "remark42", tkn.Audience)
// not escaped in these fields as they are not rendered as HTML
assert.Equal(t, badData+"::blah@user.com", tkn.Handshake.ID)
assert.Equal(t, badData, tkn.Audience)
assert.True(t, tkn.ExpiresAt > tkn.NotBefore)

assert.Equal(t, "test", e.Name())
Expand Down