- New in-memory login throttle (handler/login_throttle.go): 5 failed attempts per IP or per username within 5 minutes locks that key out for 5 minutes, applied to both /login and the TOTP verification step, which previously had no rate limiting at all - router.New now adds middleware.Secure with X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and HSTS (only when cookies are Secure, implying an HTTPS deployment). No CSP: the existing templates rely on inline <script> blocks, so a CSP strict enough to matter would need 'unsafe-inline' anyway - All session/auth cookies now set Secure based on the new --cookie-secure flag / WGUI_COOKIE_SECURE env var (default true) so the session cookie is never sent over plain HTTP unless explicitly opted into an HTTP-only LAN deployment Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
203 lines
6.7 KiB
Go
203 lines
6.7 KiB
Go
package router
|
|
|
|
import (
|
|
"errors"
|
|
"html"
|
|
"io"
|
|
"io/fs"
|
|
"reflect"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/labstack/echo-contrib/session"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
"github.com/labstack/gommon/log"
|
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
|
)
|
|
|
|
// TemplateRegistry is a custom html/template renderer for Echo framework
|
|
type TemplateRegistry struct {
|
|
templates map[string]*template.Template
|
|
extraData map[string]interface{}
|
|
}
|
|
|
|
// Render e.Renderer interface
|
|
func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
|
tmpl, ok := t.templates[name]
|
|
if !ok {
|
|
err := errors.New("Template not found -> " + name)
|
|
return err
|
|
}
|
|
|
|
// inject more app data information. E.g. appVersion
|
|
if reflect.TypeOf(data).Kind() == reflect.Map {
|
|
for k, v := range t.extraData {
|
|
data.(map[string]interface{})[k] = v
|
|
}
|
|
|
|
data.(map[string]interface{})["client_defaults"] = util.ClientDefaultsFromEnv()
|
|
}
|
|
|
|
// login page does not need the base layout
|
|
if name == "login.html" {
|
|
return tmpl.Execute(w, data)
|
|
}
|
|
|
|
return tmpl.ExecuteTemplate(w, "base.html", data)
|
|
}
|
|
|
|
// hstsMaxAge returns a one-year HSTS max-age when cookies are marked Secure
|
|
// (implying an HTTPS deployment), or 0 (no HSTS header at all) otherwise -
|
|
// sending HSTS over a deliberately HTTP-only deployment would be pointless
|
|
// and could lock out an admin who later can't reach it over HTTPS.
|
|
func hstsMaxAge(cookieSecure bool) int {
|
|
if cookieSecure {
|
|
return 31536000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// New function
|
|
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
|
|
e := echo.New()
|
|
|
|
cookiePath := util.GetCookiePath()
|
|
|
|
cookieStore := sessions.NewCookieStore(secret[:32], secret[32:])
|
|
cookieStore.Options.Path = cookiePath
|
|
cookieStore.Options.HttpOnly = true
|
|
cookieStore.Options.Secure = util.CookieSecure
|
|
cookieStore.MaxAge(86400 * 7)
|
|
|
|
e.Use(session.Middleware(cookieStore))
|
|
|
|
// read html template file to string
|
|
tmplBaseString, err := util.StringFromEmbedFile(tmplDir, "base.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplLoginString, err := util.StringFromEmbedFile(tmplDir, "login.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplProfileString, err := util.StringFromEmbedFile(tmplDir, "profile.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplClientsString, err := util.StringFromEmbedFile(tmplDir, "clients.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplGlobalSettingsString, err := util.StringFromEmbedFile(tmplDir, "global_settings.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplUsersSettingsString, err := util.StringFromEmbedFile(tmplDir, "users_settings.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplStatusString, err := util.StringFromEmbedFile(tmplDir, "status.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplWakeOnLanHostsString, err := util.StringFromEmbedFile(tmplDir, "wake_on_lan_hosts.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
aboutPageString, err := util.StringFromEmbedFile(tmplDir, "about.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplServersString, err := util.StringFromEmbedFile(tmplDir, "servers.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplServerClientsString, err := util.StringFromEmbedFile(tmplDir, "server_clients.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplFirewallListsString, err := util.StringFromEmbedFile(tmplDir, "firewall_lists.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tmplMyAccessString, err := util.StringFromEmbedFile(tmplDir, "my_access.html")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// create template list
|
|
funcs := template.FuncMap{
|
|
"StringsJoin": strings.Join,
|
|
"attrescaper": html.EscapeString,
|
|
}
|
|
templates := make(map[string]*template.Template)
|
|
templates["login.html"] = template.Must(template.New("login").Funcs(funcs).Parse(tmplLoginString))
|
|
templates["profile.html"] = template.Must(template.New("profile").Funcs(funcs).Parse(tmplBaseString + tmplProfileString))
|
|
templates["clients.html"] = template.Must(template.New("clients").Funcs(funcs).Parse(tmplBaseString + tmplClientsString))
|
|
templates["global_settings.html"] = template.Must(template.New("global_settings").Funcs(funcs).Parse(tmplBaseString + tmplGlobalSettingsString))
|
|
templates["users_settings.html"] = template.Must(template.New("users_settings").Funcs(funcs).Parse(tmplBaseString + tmplUsersSettingsString))
|
|
templates["status.html"] = template.Must(template.New("status").Funcs(funcs).Parse(tmplBaseString + tmplStatusString))
|
|
templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
|
|
templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
|
|
templates["servers.html"] = template.Must(template.New("servers").Funcs(funcs).Parse(tmplBaseString + tmplServersString))
|
|
templates["server_clients.html"] = template.Must(template.New("server_clients").Funcs(funcs).Parse(tmplBaseString + tmplServerClientsString))
|
|
templates["firewall_lists.html"] = template.Must(template.New("firewall_lists").Funcs(funcs).Parse(tmplBaseString + tmplFirewallListsString))
|
|
templates["my_access.html"] = template.Must(template.New("my_access").Funcs(funcs).Parse(tmplBaseString + tmplMyAccessString))
|
|
|
|
lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
logConfig := middleware.DefaultLoggerConfig
|
|
logConfig.Skipper = func(c echo.Context) bool {
|
|
resp := c.Response()
|
|
if resp.Status >= 500 && lvl > log.ERROR { // do not log if response is 5XX but log level is higher than ERROR
|
|
return true
|
|
} else if resp.Status >= 400 && lvl > log.WARN { // do not log if response is 4XX but log level is higher than WARN
|
|
return true
|
|
} else if lvl > log.DEBUG { // do not log if log level is higher than DEBUG
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
e.Logger.SetLevel(lvl)
|
|
e.Pre(middleware.RemoveTrailingSlash())
|
|
e.Use(middleware.LoggerWithConfig(logConfig))
|
|
// Basic hardening headers. No Content-Security-Policy here: the
|
|
// existing templates rely heavily on inline <script> blocks, and a CSP
|
|
// strict enough to matter would need 'unsafe-inline' anyway, making it
|
|
// mostly cosmetic - not worth the risk of quietly breaking the UI.
|
|
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
|
XSSProtection: "1; mode=block",
|
|
ContentTypeNosniff: "nosniff",
|
|
XFrameOptions: "SAMEORIGIN",
|
|
ReferrerPolicy: "same-origin",
|
|
HSTSMaxAge: hstsMaxAge(util.CookieSecure),
|
|
HSTSExcludeSubdomains: false,
|
|
}))
|
|
e.HideBanner = true
|
|
e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO
|
|
e.Validator = NewValidator()
|
|
e.Renderer = &TemplateRegistry{
|
|
templates: templates,
|
|
extraData: extraData,
|
|
}
|
|
|
|
return e
|
|
}
|