fix: escape server-rendered account data
This commit is contained in:
parent
657cc23e7a
commit
31f18dade1
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -113,8 +114,8 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
|
||||||
if u["blocked"].(int) != 0 {
|
if u["blocked"].(int) != 0 {
|
||||||
blocked = "🚫"
|
blocked = "🚫"
|
||||||
}
|
}
|
||||||
w.Write([]byte(`<tr><td>` + u["username"].(string) + `</td><td>` + u["email"].(string) +
|
w.Write([]byte(`<tr><td>` + html.EscapeString(u["username"].(string)) + `</td><td>` + html.EscapeString(u["email"].(string)) +
|
||||||
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + u["created_at"].(string) + `</td></tr>`))
|
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + html.EscapeString(u["created_at"].(string)) + `</td></tr>`))
|
||||||
}
|
}
|
||||||
w.Write([]byte(`</table></body></html>`))
|
w.Write([]byte(`</table></body></html>`))
|
||||||
}
|
}
|
||||||
|
|
@ -148,8 +149,8 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
|
||||||
if revokedAt == "" {
|
if revokedAt == "" {
|
||||||
revokedAt = "-"
|
revokedAt = "-"
|
||||||
}
|
}
|
||||||
w.Write([]byte(`<tr><td>` + name + `</td><td style="font-family:monospace;font-size:0.8em">` + id +
|
w.Write([]byte(`<tr><td>` + html.EscapeString(name) + `</td><td style="font-family:monospace;font-size:0.8em">` + html.EscapeString(id) +
|
||||||
`</td><td>` + clientVer + `</td><td>` + lastSeen + `</td><td>` + revokedAt + `</td><td>` + createdAt + `</td></tr>`))
|
`</td><td>` + html.EscapeString(clientVer) + `</td><td>` + html.EscapeString(lastSeen) + `</td><td>` + html.EscapeString(revokedAt) + `</td><td>` + html.EscapeString(createdAt) + `</td></tr>`))
|
||||||
}
|
}
|
||||||
w.Write([]byte(`</table></body></html>`))
|
w.Write([]byte(`</table></body></html>`))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -187,8 +190,8 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
html := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", token)
|
page := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", html.EscapeString(token))
|
||||||
w.Write([]byte(html))
|
w.Write([]byte(page))
|
||||||
case "POST":
|
case "POST":
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
jsonErr(w, 400, "bad form")
|
jsonErr(w, 400, "bad form")
|
||||||
|
|
@ -204,12 +207,12 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
if err := validatePassword(newPass); err != "" {
|
if err := validatePassword(newPass); err != "" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+token)))
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+url.QueryEscape(token))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if newPass != confirm {
|
if newPass != confirm {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+token)))
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+url.QueryEscape(token))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID, err := s.resetPasswordWithToken(token, newPass)
|
userID, err := s.resetPasswordWithToken(token, newPass)
|
||||||
|
|
@ -310,7 +313,7 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
created = created[:10]
|
created = created[:10]
|
||||||
}
|
}
|
||||||
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
|
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
|
||||||
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">%s</button>`, d.ID, t(locale, "userDashboard.revoke"))
|
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice(%s)">%s</button>`, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke"))
|
||||||
if d.RevokedAt != "" {
|
if d.RevokedAt != "" {
|
||||||
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
|
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
|
||||||
revokeBtn = ""
|
revokeBtn = ""
|
||||||
|
|
@ -321,11 +324,11 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
<td>%s</td>
|
<td>%s</td>
|
||||||
<td>%s</td>
|
<td>%s</td>
|
||||||
<td>%s %s</td>
|
<td>%s %s</td>
|
||||||
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
</tr>`, html.EscapeString(d.Name), status, html.EscapeString(created), html.EscapeString(ls), html.EscapeString(d.ClientVer), revokeBtn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Write([]byte(userDashboardHTML(locale, username, deviceRows)))
|
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -372,6 +373,74 @@ func TestWebResetRejectsExpiredToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
|
||||||
|
s, _ := newSyncHTTPServer(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
const maliciousText = `<img src=x onerror=alert(1)>`
|
||||||
|
const maliciousDeviceID = `device');alert(1);//`
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO server_users
|
||||||
|
(id, username, email, password_hash, confirmed, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?)`, "user-a", maliciousText, maliciousText+"@example.test", "unused", now); err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO server_devices
|
||||||
|
(id, name, api_key, user_id, vault_id, client_version, last_seen, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, maliciousDeviceID, maliciousText, "device-key", "user-a", "vault-a", maliciousText, now, now); err != nil {
|
||||||
|
t.Fatalf("insert device: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)", "user-a", maliciousDeviceID); err != nil {
|
||||||
|
t.Fatalf("link user device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
cookie *http.Cookie
|
||||||
|
containsDevID bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "user dashboard",
|
||||||
|
path: "/dashboard",
|
||||||
|
cookie: &http.Cookie{Name: "user_session", Value: s.userTokens.Create("user-a")},
|
||||||
|
containsDevID: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin users",
|
||||||
|
path: "/admin/users",
|
||||||
|
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin devices",
|
||||||
|
path: "/admin/devices",
|
||||||
|
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
|
||||||
|
containsDevID: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||||
|
req.AddCookie(tt.cookie)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
s.mux.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", res.Code)
|
||||||
|
}
|
||||||
|
body := res.Body.String()
|
||||||
|
if strings.Contains(body, maliciousText) {
|
||||||
|
t.Fatalf("page contains unescaped stored text: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, html.EscapeString(maliciousText)) {
|
||||||
|
t.Fatalf("page does not contain escaped stored text: %s", body)
|
||||||
|
}
|
||||||
|
if tt.containsDevID && strings.Contains(body, maliciousDeviceID) {
|
||||||
|
t.Fatalf("page contains unescaped device ID: %s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
|
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dbPath := filepath.Join(dir, "legacy.db")
|
dbPath := filepath.Join(dir, "legacy.db")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -552,6 +553,9 @@ button:hover{background:#4f46e5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func errorPageHTML(locale, title, msg, backURL string) string {
|
func errorPageHTML(locale, title, msg, backURL string) string {
|
||||||
|
title = html.EscapeString(title)
|
||||||
|
msg = html.EscapeString(msg)
|
||||||
|
backURL = html.EscapeString(backURL)
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue