fixa aumento no serial do soa

This commit is contained in:
2026-06-19 18:47:34 -03:00
parent 968f4ef5d9
commit 1901055e25
9 changed files with 353 additions and 231 deletions

View File

@@ -3,6 +3,7 @@ package server
import (
"context"
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"fmt"
@@ -23,7 +24,8 @@ import (
var assets embed.FS
const (
sessionCookieName = "pdns_admin_session"
csrfFieldName = "csrf_token"
sessionCookieName = "__Host-pdns_admin_session"
sessionTTL = 12 * time.Hour
)
@@ -62,6 +64,7 @@ type pageData struct {
Error string
AuthEnabled bool
CurrentUser string
CSRFToken string
Next string
Server pdns.Server
ZoneID string
@@ -72,21 +75,19 @@ type pageData struct {
}
type session struct {
Username string
Expires time.Time
Username string
CSRFToken string
Expires time.Time
}
type recordForm struct {
Name string
Type string
TTL uint32
Records string
OriginalName string
OriginalType string
IsEdit bool
IsSOA bool
Title string
SubmitLabel string
Name string
Type string
TTL uint32
Records string
IsEdit bool
Title string
SubmitLabel string
}
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
@@ -106,9 +107,8 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
templates := make(map[string]*template.Template)
funcs := template.FuncMap{
"isSOA": isSOA,
"recordValues": recordValues,
"urlQuery": url.QueryEscape,
"isSOA": isSOA,
"urlQuery": url.QueryEscape,
}
for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} {
tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page)
@@ -139,7 +139,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /login", s.login)
mux.HandleFunc("POST /login", s.loginPost)
mux.HandleFunc("GET /logout", s.logout)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /", s.dashboard)
mux.HandleFunc("GET /zones", s.listZones)
mux.HandleFunc("POST /zones", s.createZone)
@@ -150,7 +150,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet)
return s.withLogging(s.withSessionAuth(mux))
return s.withLogging(s.withSecurityHeaders(s.withSessionAuth(mux)))
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
@@ -226,8 +226,10 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
Value: token,
Path: "/",
Expires: time.Now().Add(sessionTTL),
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, safeRedirectPath(r.FormValue("next")), http.StatusSeeOther)
@@ -244,7 +246,8 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
@@ -363,7 +366,6 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
TTL: rrset.TTL,
Records: recordValues(rrset),
IsEdit: true,
IsSOA: isSOA(rrset.Type),
Title: "Edit record",
SubmitLabel: "Save record",
}
@@ -445,7 +447,7 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
return
}
name := ensureTrailingDot(r.FormValue("name"))
name := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
if name == "." || recordType == "" {
s.redirectZoneError(w, r, zoneID, "record name and type are required")
@@ -466,8 +468,9 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.AuthEnabled = s.auth != nil
if user, ok := s.currentUser(r); ok {
data.CurrentUser = user
if sess, ok := s.currentSession(r); ok {
data.CurrentUser = sess.Username
data.CSRFToken = sess.CSRFToken
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, ok := s.templates[name]
@@ -498,46 +501,64 @@ func (s *Server) withSessionAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
if _, ok := s.currentUser(r); !ok {
sess, ok := s.currentSession(r)
if !ok {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
if isUnsafeMethod(r.Method) && !validCSRFToken(r, sess.CSRFToken) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) currentUser(r *http.Request) (string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
sess, ok := s.currentSession(r)
if !ok {
return "", false
}
return sess.Username, true
}
func (s *Server) currentSession(r *http.Request) (session, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
return session{}, false
}
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
sess, ok := s.sessions[cookie.Value]
if !ok {
return "", false
return session{}, false
}
if time.Now().After(sess.Expires) {
delete(s.sessions, cookie.Value)
return "", false
return session{}, false
}
return sess.Username, true
return sess, true
}
func (s *Server) createSession(username string) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
token, err := randomToken()
if err != nil {
return "", err
}
csrfToken, err := randomToken()
if err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
s.pruneExpiredSessionsLocked(time.Now())
s.sessions[token] = session{
Username: username,
Expires: time.Now().Add(sessionTTL),
Username: username,
CSRFToken: csrfToken,
Expires: time.Now().Add(sessionTTL),
}
return token, nil
}
@@ -548,8 +569,16 @@ func (s *Server) deleteSession(token string) {
delete(s.sessions, token)
}
func (s *Server) pruneExpiredSessionsLocked(now time.Time) {
for token, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, token)
}
}
}
func isPublicPath(path string) bool {
return path == "/login" || path == "/logout" || path == "/healthz" || strings.HasPrefix(path, "/static/")
return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
}
func safeRedirectPath(value string) string {
@@ -563,6 +592,52 @@ func safeRedirectPath(value string) string {
return parsed.RequestURI()
}
func isUnsafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return false
default:
return true
}
}
func validCSRFToken(r *http.Request, expected string) bool {
if expected == "" {
return false
}
if err := r.ParseForm(); err != nil {
return false
}
token := r.FormValue(csrfFieldName)
if token == "" {
token = r.Header.Get("X-CSRF-Token")
}
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}
func randomToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(tokenBytes), nil
}
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
@@ -584,10 +659,6 @@ func parseLines(raw string) []string {
return values
}
func ensureTrailingDot(value string) string {
return dnsrecord.EnsureTrailingDot(value)
}
func parseFQDNLines(raw, label string) ([]string, error) {
values := parseLines(raw)
for i, value := range values {

View File

@@ -154,6 +154,15 @@ func TestLoginCreatesSession(t *testing.T) {
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
t.Fatalf("expected session cookie, got %#v", cookies)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
if !cookies[0].Secure {
t.Fatal("session cookie must be Secure")
}
if cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("unexpected SameSite policy: %v", cookies[0].SameSite)
}
req = httptest.NewRequest(http.MethodGet, "/zones", nil)
req.AddCookie(cookies[0])
@@ -196,9 +205,12 @@ func TestLogoutClearsSession(t *testing.T) {
if err != nil {
t.Fatalf("createSession returned error: %v", err)
}
csrfToken := srv.sessions[token].CSRFToken
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
body := strings.NewReader("csrf_token=" + csrfToken)
req := httptest.NewRequest(http.MethodPost, "/logout", body)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
@@ -211,6 +223,51 @@ func TestLogoutClearsSession(t *testing.T) {
}
}
func TestLogoutRequiresCSRF(t *testing.T) {
srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
token, err := srv.createSession("alice")
if err != nil {
t.Fatalf("createSession returned error: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d", rec.Code)
}
}
func TestSecurityHeadersAreSet(t *testing.T) {
srv, err := New(Config{}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
for _, header := range []string{
"Content-Security-Policy",
"Referrer-Policy",
"Strict-Transport-Security",
"X-Content-Type-Options",
"X-Frame-Options",
} {
if rec.Header().Get(header) == "" {
t.Fatalf("expected %s header", header)
}
}
}
func TestParseLinesSkipsBlankLines(t *testing.T) {
values := parseLines("192.0.2.1\n\n192.0.2.2\n")
if len(values) != 2 {

View File

@@ -31,9 +31,12 @@
{{ if .CurrentUser }}
<div class="navbar-nav ms-auto">
<span class="nav-link text-secondary">{{ .CurrentUser }}</span>
<a class="nav-link" href="/logout">
<span class="nav-link-title">Logout</span>
</a>
<form method="post" action="/logout" class="nav-item">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<button class="nav-link btn btn-link px-0" type="submit">
<span class="nav-link-title">Logout</span>
</button>
</form>
</div>
{{ end }}
{{ end }}

View File

@@ -29,6 +29,7 @@
{{ end }}
<form method="post" action="{{ if .RecordForm.IsEdit }}/zones/{{ .ZoneID }}/rrsets/edit?name={{ urlQuery .RecordForm.Name }}&type={{ urlQuery .RecordForm.Type }}{{ else }}/zones/{{ .ZoneID }}/rrsets{{ end }}">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ if not .RecordForm.IsEdit }}
<div class="mb-3">
<label class="form-label">Name</label>

View File

@@ -49,6 +49,7 @@
<a class="btn btn-outline-primary btn-sm" href="/zones/{{ $.ZoneID }}/rrsets/edit?name={{ urlQuery .Name }}&type={{ urlQuery .Type }}">Edit</a>
{{ if not (isSOA .Type) }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="name" value="{{ .Name }}">
<input type="hidden" name="type" value="{{ .Type }}">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>

View File

@@ -34,6 +34,7 @@
<td>{{ .Serial }}</td>
<td>
<form method="post" action="/zones/{{ .ID }}/delete">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</td>
@@ -55,6 +56,7 @@
</div>
<div class="card-body">
<form method="post" action="/zones">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3">
<label class="form-label">Zone name</label>
<input class="form-control" name="name" placeholder="example.org." required>