Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
460c11a7bf | ||
|
|
7f6139e6d6 |
@@ -75,7 +75,7 @@ or partial matching is supported.
|
|||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
@@ -87,13 +87,20 @@ Where:
|
|||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — Unix timestamp when signature expires
|
- `expiration` — Unix timestamp when signature expires
|
||||||
|
- `quality` — output quality 1-100; sign `85` (the default) when the URL
|
||||||
|
omits the `q` parameter
|
||||||
|
- `fit` — fit mode (cover, contain, fill, inside, outside); sign `cover`
|
||||||
|
(the default) when the URL omits the `fit` parameter
|
||||||
|
|
||||||
**Example:** resize
|
The `q` and `fit` query parameters are covered by the signature. A URL
|
||||||
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
signed for one quality or fit value will not verify when replayed with a
|
||||||
expiration 1704067200:
|
different value; the effective (post-default) value is what is signed.
|
||||||
|
|
||||||
|
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
|
||||||
|
WebP with expiration 1704067200, default quality and fit:
|
||||||
|
|
||||||
1. Build input:
|
1. Build input:
|
||||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
||||||
2. Compute HMAC-SHA256 with your secret key
|
2. Compute HMAC-SHA256 with your secret key
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result
|
||||||
4. URL:
|
4. URL:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ require (
|
|||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/gorilla/csrf v1.7.3
|
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
|
|||||||
@@ -175,8 +175,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
|
||||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
|
|||||||
+13
-23
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,13 +23,13 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
|
|
||||||
// Check if authenticated
|
// Check if authenticated
|
||||||
if s.sessMgr.IsAuthenticated(r) {
|
if s.sessMgr.IsAuthenticated(r) {
|
||||||
s.renderGenerator(w, r, nil)
|
s.renderGenerator(w, nil)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login page
|
// Show login page
|
||||||
s.renderLogin(w, r, "")
|
s.renderLogin(w, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderLogin(w, r, "Invalid form data")
|
s.renderLogin(w, "Invalid form data")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -48,7 +47,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
||||||
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
||||||
s.renderLogin(w, r, "Invalid signing key")
|
s.renderLogin(w, "Invalid signing key")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = s.sessMgr.CreateSession(w)
|
err = s.sessMgr.CreateSession(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to create session", "error", err)
|
s.log.Error("failed to create session", "error", err)
|
||||||
s.renderLogin(w, r, "Failed to create session")
|
s.renderLogin(w, "Failed to create session")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,7 +87,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
|
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,7 +97,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
|
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
|
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
expiresAtStr = expiresAt.Format(time.RFC3339)
|
expiresAtStr = expiresAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGenerator(w, &generatorData{
|
||||||
GeneratedURL: generatedURL,
|
GeneratedURL: generatedURL,
|
||||||
ExpiresAt: expiresAtStr,
|
ExpiresAt: expiresAtStr,
|
||||||
FormURL: sourceURL,
|
FormURL: sourceURL,
|
||||||
@@ -187,20 +186,15 @@ type generatorData struct {
|
|||||||
FormQuality string
|
FormQuality string
|
||||||
FormFit string
|
FormFit string
|
||||||
FormTTL string
|
FormTTL string
|
||||||
CSRFField template.HTML
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderLogin(
|
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string,
|
|
||||||
) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Error string
|
Error string
|
||||||
CSRFField template.HTML
|
|
||||||
}{
|
}{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
CSRFField: csrfField(r),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := templates.Render(w, "login.html", data)
|
err := templates.Render(w, "login.html", data)
|
||||||
@@ -210,17 +204,13 @@ func (s *Handlers) renderLogin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGenerator(
|
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
|
||||||
) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = &generatorData{}
|
data = &generatorData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.CSRFField = csrfField(r)
|
|
||||||
|
|
||||||
err := templates.Render(w, "generator.html", data)
|
err := templates.Render(w, "generator.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
@@ -229,9 +219,9 @@ func (s *Handlers) renderGenerator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
w http.ResponseWriter, errorMsg string, form url.Values,
|
||||||
) {
|
) {
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGenerator(w, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testSigningKey is a throwaway signing key for the CSRF flow tests. It
|
|
||||||
// seeds the session manager, the encrypted-URL generator, and the CSRF
|
|
||||||
// token key, exactly as the real signing key does in production.
|
|
||||||
const testSigningKey = "test-signing-key-0123456789abcdef"
|
|
||||||
|
|
||||||
// Form field names used in the CSRF flow tests.
|
|
||||||
const (
|
|
||||||
loginKeyField = "key"
|
|
||||||
// gorilla/csrf's default form field name, not a credential.
|
|
||||||
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
|
|
||||||
// the form. The field name is gorilla/csrf's default.
|
|
||||||
var csrfFieldPattern = regexp.MustCompile(
|
|
||||||
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
|
||||||
|
|
||||||
// newCSRFTestRouter builds a router that mirrors the production wiring for
|
|
||||||
// the CSRF-protected UI routes (see server.SetupRoutes): the login and
|
|
||||||
// generator forms and their POST targets sit behind the real CSRF
|
|
||||||
// middleware. Requests are marked plaintext (Debug: true) so the flow runs
|
|
||||||
// over httptest's http transport without an https Referer.
|
|
||||||
func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
|
||||||
|
|
||||||
sessMgr, err := session.NewManager(testSigningKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("session.NewManager() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("newCSRFProtect() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &Handlers{
|
|
||||||
log: slog.New(slog.DiscardHandler),
|
|
||||||
config: cfg,
|
|
||||||
sessMgr: sessMgr,
|
|
||||||
encGen: encGen,
|
|
||||||
csrfProtect: protect,
|
|
||||||
}
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(h.CSRF())
|
|
||||||
r.Get("/", h.HandleRoot())
|
|
||||||
r.Post("/", h.HandleRoot())
|
|
||||||
r.Post("/generate", h.HandleGenerateURL())
|
|
||||||
})
|
|
||||||
|
|
||||||
return h, r
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfCredentials performs a GET that renders a form and returns the CSRF
|
|
||||||
// cookies the middleware set and the token embedded in the form. Passing
|
|
||||||
// the authenticated session cookie renders the generator form instead of
|
|
||||||
// the login form.
|
|
||||||
func csrfCredentials(
|
|
||||||
t *testing.T, srv http.Handler, reqCookies []*http.Cookie,
|
|
||||||
) ([]*http.Cookie, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
|
||||||
for _, c := range reqCookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
match := csrfFieldPattern.FindStringSubmatch(rec.Body.String())
|
|
||||||
if match == nil {
|
|
||||||
t.Fatalf("no CSRF token field found in rendered form")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec.Result().Cookies(), match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// postForm submits form values with the given cookies and returns the
|
|
||||||
// recorder.
|
|
||||||
func postForm(
|
|
||||||
srv http.Handler, path string,
|
|
||||||
cookies []*http.Cookie, form url.Values,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost, path,
|
|
||||||
strings.NewReader(form.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token
|
|
||||||
// is rejected. This is login CSRF: no session cookie exists yet, so the
|
|
||||||
// protection must rest on a token bound to a pre-session cookie.
|
|
||||||
func TestLoginPostRejectedWithoutToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST / without token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostRejectedWithForeignToken verifies that a token that does not
|
|
||||||
// match the request's CSRF cookie is rejected: a token minted for one
|
|
||||||
// cookie cannot authorize a request carrying a different cookie.
|
|
||||||
func TestLoginPostRejectedWithForeignToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
cookiesA, _ := csrfCredentials(t, srv, nil)
|
|
||||||
_, tokenB := csrfCredentials(t, srv, nil)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", cookiesA, url.Values{
|
|
||||||
loginKeyField: {testSigningKey},
|
|
||||||
csrfTokenField: {tokenB},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST / with foreign token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching
|
|
||||||
// cookie and token succeeds: the login is processed and a session is
|
|
||||||
// established (303 redirect).
|
|
||||||
func TestLoginPostAcceptedWithValidToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
cookies, token := csrfCredentials(t, srv, nil)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", cookies, url.Values{
|
|
||||||
loginKeyField: {testSigningKey},
|
|
||||||
csrfTokenField: {token},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("POST / with valid token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
var authed bool
|
|
||||||
|
|
||||||
for _, c := range rec.Result().Cookies() {
|
|
||||||
if c.Name == session.CookieName && c.Value != "" {
|
|
||||||
authed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !authed {
|
|
||||||
t.Error("valid login did not set a session cookie")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostRejectedWithoutToken verifies that POST /generate is
|
|
||||||
// rejected without a CSRF token even when the request carries a valid
|
|
||||||
// authenticated session. The session cookie is not sufficient; the policy
|
|
||||||
// requires a CSRF token on this cookie-authenticated form.
|
|
||||||
func TestGeneratePostRejectedWithoutToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
sessionCookie := newSessionCookie(t, h)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/generate",
|
|
||||||
[]*http.Cookie{sessionCookie},
|
|
||||||
url.Values{"url": {"https://example.com/a.jpg"}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST /generate without token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostAcceptedWithValidToken verifies that POST /generate
|
|
||||||
// succeeds with a valid session and a matching CSRF cookie and token.
|
|
||||||
func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
sessionCookie := newSessionCookie(t, h)
|
|
||||||
|
|
||||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
|
||||||
cookies = append(cookies, sessionCookie)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
|
||||||
"url": {"https://example.com/a.jpg"},
|
|
||||||
"format": {"jpeg"},
|
|
||||||
csrfTokenField: {token},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /generate with valid token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
|
||||||
t.Error("generator response did not contain a generated URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSessionCookie creates an authenticated session cookie via the
|
|
||||||
// handler's session manager.
|
|
||||||
func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
err := h.sessMgr.CreateSession(rec)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSession() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range rec.Result().Cookies() {
|
|
||||||
if c.Name == session.CookieName {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("session manager did not set a %q cookie", session.CookieName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfKeySalt provides domain separation for the CSRF authentication key,
|
|
||||||
// derived from the signing key so tokens survive restarts without extra
|
|
||||||
// configuration and never reuse the session or encrypted-URL key material.
|
|
||||||
const csrfKeySalt = "pixa-csrf-v1"
|
|
||||||
|
|
||||||
// newCSRFProtect builds the CSRF-protection middleware for the
|
|
||||||
// state-mutating HTML form routes. The token lives in its own cookie,
|
|
||||||
// independent of the session cookie, so it also protects the login POST
|
|
||||||
// where no session exists yet (login CSRF).
|
|
||||||
//
|
|
||||||
// When plaintext is true (local HTTP development), requests are marked
|
|
||||||
// plaintext so the library neither demands an https Referer nor sets a
|
|
||||||
// Secure cookie the browser would withhold over http. In production the
|
|
||||||
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
|
||||||
// the library enforces its https Referer origin check.
|
|
||||||
func newCSRFProtect(
|
|
||||||
signingKey string, plaintext bool,
|
|
||||||
) (func(http.Handler) http.Handler, error) {
|
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
protect := csrf.Protect(
|
|
||||||
key[:],
|
|
||||||
csrf.Path("/"),
|
|
||||||
csrf.Secure(!plaintext),
|
|
||||||
csrf.SameSite(csrf.SameSiteStrictMode),
|
|
||||||
)
|
|
||||||
|
|
||||||
if !plaintext {
|
|
||||||
return protect, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
protected := protect(next)
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
|
||||||
})
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF returns the CSRF-protection middleware for the login and generator
|
|
||||||
// form routes.
|
|
||||||
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
|
||||||
return s.csrfProtect
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfField returns the hidden form input carrying the CSRF token for the
|
|
||||||
// given request, to be embedded verbatim in a rendered form.
|
|
||||||
func csrfField(r *http.Request) template.HTML {
|
|
||||||
return csrf.TemplateField(r)
|
|
||||||
}
|
|
||||||
@@ -39,22 +39,15 @@ type Handlers struct {
|
|||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Handlers{
|
s := &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
csrfProtect: csrfProtect,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
|||||||
@@ -452,6 +452,8 @@ func signatureRequest(req *ImageRequest) *signature.Request {
|
|||||||
Width: req.Size.Width,
|
Width: req.Size.Width,
|
||||||
Height: req.Size.Height,
|
Height: req.Size.Height,
|
||||||
Format: string(req.Format),
|
Format: string(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: string(req.FitMode),
|
||||||
Signature: req.Signature,
|
Signature: req.Signature,
|
||||||
Expires: req.Expires,
|
Expires: req.Expires,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,17 +44,11 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Static files (Tailwind CSS, etc.)
|
// Static files (Tailwind CSS, etc.)
|
||||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||||
|
|
||||||
// Login/generator UI. The form routes carry CSRF protection; the
|
// Login/generator UI
|
||||||
// token cookie is independent of the session cookie, so it also
|
s.router.Get("/", s.h.HandleRoot())
|
||||||
// covers the login POST, where no session exists yet.
|
s.router.Post("/", s.h.HandleRoot())
|
||||||
s.router.Group(func(r chi.Router) {
|
|
||||||
r.Use(s.h.CSRF())
|
|
||||||
r.Get("/", s.h.HandleRoot())
|
|
||||||
r.Post("/", s.h.HandleRoot())
|
|
||||||
r.Post("/generate", s.h.HandleGenerateURL())
|
|
||||||
})
|
|
||||||
|
|
||||||
s.router.Get("/logout", s.h.HandleLogout())
|
s.router.Get("/logout", s.h.HandleLogout())
|
||||||
|
s.router.Post("/generate", s.h.HandleGenerateURL())
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed inputs for the quality/fit golden vectors. They are independent of
|
||||||
|
// the constants in golden_test.go so this file pins the current signed
|
||||||
|
// format on its own.
|
||||||
|
const (
|
||||||
|
qfSigningKey = "golden-test-key"
|
||||||
|
qfExpiresUnix int64 = 1704067200 // 2024-01-01T00:00:00Z
|
||||||
|
qfFitCover = "cover"
|
||||||
|
qfFitContain = "contain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type qualityFitGoldenVector struct {
|
||||||
|
name string
|
||||||
|
req signature.Request
|
||||||
|
// wantSignature is the exact base64url (RFC 4648 URL-safe, padded)
|
||||||
|
// HMAC-SHA256 signature for the request with Expires set to
|
||||||
|
// qfExpiresUnix, under the signed format
|
||||||
|
// "host:path:query:width:height:format:expiration:quality:fit".
|
||||||
|
wantSignature string
|
||||||
|
}
|
||||||
|
|
||||||
|
// qualityFitGoldenVectors returns the known-answer vectors that pin quality
|
||||||
|
// and fit as signed components. The three default-value vectors use the
|
||||||
|
// effective quality (85) and fit ("cover") the handler applies when a URL
|
||||||
|
// omits q and fit, so they are the signatures real signed URLs must carry.
|
||||||
|
func qualityFitGoldenVectors() []qualityFitGoldenVector {
|
||||||
|
return []qualityFitGoldenVector{
|
||||||
|
{
|
||||||
|
name: "resized, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
|
||||||
|
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resized with query, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
|
||||||
|
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 0,
|
||||||
|
Height: 0,
|
||||||
|
Format: testFormatPNG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
|
||||||
|
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 40,
|
||||||
|
FitMode: qfFitContain,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
|
||||||
|
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_GoldenVectors_QualityFit pins the exact HMAC-SHA256 signature
|
||||||
|
// output for requests that carry quality and fit as signed components. If
|
||||||
|
// these assertions fail, the signed byte format
|
||||||
|
// ("host:path:query:width:height:format:expiration:quality:fit") or the
|
||||||
|
// base64url encoding has changed, breaking every signature already issued.
|
||||||
|
// Update these constants only as part of a deliberate, documented signature
|
||||||
|
// format migration.
|
||||||
|
func TestSigner_GoldenVectors_QualityFit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New(qfSigningKey)
|
||||||
|
|
||||||
|
for _, tt := range qualityFitGoldenVectors() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signReq := tt.req
|
||||||
|
signReq.Expires = time.Unix(qfExpiresUnix, 0)
|
||||||
|
|
||||||
|
gotSignature := signer.Sign(&signReq)
|
||||||
|
if gotSignature != tt.wantSignature {
|
||||||
|
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
|
||||||
|
gotSignature, tt.wantSignature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedQualityFitRequest returns a request signed for quality 85 and fit
|
||||||
|
// mode "cover", the effective defaults the handler applies before
|
||||||
|
// verification.
|
||||||
|
func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: "cover",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
|
||||||
|
// covered by the signature: a URL signed for one quality or fit mode must
|
||||||
|
// not verify when replayed with a different quality or fit mode. This is the
|
||||||
|
// amplification vector from the issue — one signed URL replayed across many
|
||||||
|
// quality and fit values yields many unauthorized cache entries and
|
||||||
|
// transcodes — so it must be rejected.
|
||||||
|
func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tamper func(r *signature.Request)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "replayed with different quality",
|
||||||
|
tamper: func(r *signature.Request) { r.Quality = 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replayed with different fit mode",
|
||||||
|
tamper: func(r *signature.Request) { r.FitMode = "contain" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := signedQualityFitRequest(signer)
|
||||||
|
tt.tamper(req)
|
||||||
|
|
||||||
|
err := signer.Verify(req)
|
||||||
|
if !errors.Is(err, signature.ErrInvalid) {
|
||||||
|
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@ type Request struct {
|
|||||||
Height int
|
Height int
|
||||||
// Format is the requested output format (e.g. "webp").
|
// Format is the requested output format (e.g. "webp").
|
||||||
Format string
|
Format string
|
||||||
|
// Quality is the requested output quality (1-100) for lossy formats.
|
||||||
|
// It is the effective value the request resolves to: callers pass the
|
||||||
|
// default quality when the request omits the parameter, so an omitted
|
||||||
|
// quality signs identically to that same value stated explicitly.
|
||||||
|
Quality int
|
||||||
|
// FitMode is how the image is fit into the requested dimensions
|
||||||
|
// (e.g. "cover"). Like Quality it is the effective value: callers pass
|
||||||
|
// the default fit mode when the request omits the parameter.
|
||||||
|
FitMode string
|
||||||
// Signature is the HMAC signature to verify.
|
// Signature is the HMAC signature to verify.
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp.
|
// Expires is the signature expiration timestamp.
|
||||||
@@ -56,7 +65,8 @@ func New(secretKey string) *Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given request.
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
||||||
// The signature covers: host + path + query + width + height + format + expiration.
|
// The signature covers: host + path + query + width + height + format +
|
||||||
|
// expiration + quality + fit.
|
||||||
func (s *Signer) Sign(req *Request) string {
|
func (s *Signer) Sign(req *Request) string {
|
||||||
data := s.buildSignatureData(req)
|
data := s.buildSignatureData(req)
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
mac := hmac.New(sha256.New, s.secretKey)
|
||||||
@@ -68,7 +78,8 @@ func (s *Signer) Sign(req *Request) string {
|
|||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
// Signatures are exact-match only: every component of the signed data
|
// Signatures are exact-match only: every component of the signed data
|
||||||
// (host, path, query, dimensions, format, expiration) must match exactly.
|
// (host, path, query, dimensions, format, expiration, quality, fit) must
|
||||||
|
// match exactly.
|
||||||
// No suffix matching, wildcard matching, or partial matching is supported.
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
// "other.cdn.example.com", and vice versa.
|
// "other.cdn.example.com", and vice versa.
|
||||||
@@ -142,11 +153,13 @@ func (s *Signer) GenerateSignedURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
// buildSignatureData creates the string to be signed.
|
||||||
// Format: "host:path:query:width:height:format:expiration"
|
// Format: "host:path:query:width:height:format:expiration:quality:fit"
|
||||||
// All components are used verbatim (exact match). No normalization,
|
// All components are used verbatim (exact match). No normalization,
|
||||||
// suffix matching, or wildcard expansion is performed.
|
// suffix matching, or wildcard expansion is performed. Quality and fit
|
||||||
|
// are the effective transform values, so replaying a signed URL with a
|
||||||
|
// different quality or fit mode fails verification.
|
||||||
func (s *Signer) buildSignatureData(req *Request) string {
|
func (s *Signer) buildSignatureData(req *Request) string {
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
||||||
req.SourceHost,
|
req.SourceHost,
|
||||||
req.SourcePath,
|
req.SourcePath,
|
||||||
req.SourceQuery,
|
req.SourceQuery,
|
||||||
@@ -154,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
|
|||||||
req.Height,
|
req.Height,
|
||||||
req.Format,
|
req.Format,
|
||||||
req.Expires.Unix(),
|
req.Expires.Unix(),
|
||||||
|
req.Quality,
|
||||||
|
req.FitMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||||
{{ .CSRFField }}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Source URL
|
Source URL
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/" class="space-y-4">
|
<form method="POST" action="/" class="space-y-4">
|
||||||
{{ .CSRFField }}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Signing Key
|
Signing Key
|
||||||
|
|||||||
Reference in New Issue
Block a user