mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-07 17:43:04 -10:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd39272774 | ||
|
|
174f6e4a56 | ||
|
|
5e38dba461 | ||
|
|
2f3a60298e | ||
|
|
682e323b4b | ||
|
|
c6f30fc3aa | ||
|
|
3327fa8b22 | ||
|
|
d5cd80a8ec | ||
|
|
be7b7539e2 | ||
|
|
1c5534968e | ||
|
|
150502a60c | ||
|
|
fa6943f6fc | ||
|
|
93591b2eb7 | ||
|
|
2b97897ee9 | ||
|
|
c0dad356a5 | ||
|
|
72632f6b34 | ||
|
|
208e5669a1 | ||
|
|
5067566e0f | ||
|
|
9dc7d5b9c6 | ||
|
|
b774d7065f | ||
|
|
e32f344e30 | ||
|
|
9762904e2a |
No files matched your search
@@ -386,8 +386,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -401,8 +401,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -380,8 +380,18 @@ CLOUD:
|
||||
-pdu, -dashboard-upload string upload / view nuclei results file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
|
||||
|
||||
AUTHENTICATION:
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-sf, -secret-file string[] path to config file containing secrets for nuclei authenticated scan
|
||||
-ps, -prefetch-secrets prefetch secrets from the secrets file
|
||||
-alu, -auth-login-url string login page url for turnkey auto-login authenticated scan
|
||||
-au, -auth-username string username for auto-login (-auth-login-url)
|
||||
-apw, -auth-password string password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure
|
||||
-auf, -auth-username-field string override the detected username form field name for auto-login
|
||||
-apf, -auth-password-field string override the detected password form field name for auto-login
|
||||
-ah, -auth-headless drive auto-login through a real browser (JS/SPA/SSO login pages)
|
||||
-ar, -auth-recording string path to a chrome devtools recorder json login flow to replay for auto-login
|
||||
-ac, -auth-capture open a browser to log in manually once, then capture the session for the scan
|
||||
-arsc, -auth-reauth-status-codes string comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan
|
||||
-ari, -auth-refresh-interval string re-run the auto-login once the captured session is older than this duration (e.g. 15m)
|
||||
# NOTE: Headers in secrets files preserve exact casing (useful for case-sensitive APIs)
|
||||
|
||||
|
||||
|
||||
@@ -539,6 +539,16 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.CreateGroup("Authentication", "Authentication",
|
||||
flagSet.StringSliceVarP(&options.SecretsFile, "secret-file", "sf", nil, "path to config file containing secrets for nuclei authenticated scan", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.PreFetchSecrets, "prefetch-secrets", "ps", false, "prefetch secrets from the secrets file"),
|
||||
flagSet.StringVarP(&options.AuthLoginURL, "auth-login-url", "alu", "", "login page url for turnkey auto-login authenticated scan"),
|
||||
flagSet.StringVarP(&options.AuthUsername, "auth-username", "au", "", "username for auto-login (-auth-login-url)"),
|
||||
flagSet.StringVarP(&options.AuthPassword, "auth-password", "apw", "", "password for auto-login (-auth-login-url); prefer NUCLEI_AUTH_PASSWORD to avoid argv exposure"),
|
||||
flagSet.StringVarP(&options.AuthUsernameField, "auth-username-field", "auf", "", "override the detected username form field name for auto-login"),
|
||||
flagSet.StringVarP(&options.AuthPasswordField, "auth-password-field", "apf", "", "override the detected password form field name for auto-login"),
|
||||
flagSet.BoolVarP(&options.AuthHeadless, "auth-headless", "ah", false, "drive auto-login through a real browser (JS/SPA/SSO login pages)"),
|
||||
flagSet.StringVarP(&options.AuthRecording, "auth-recording", "ar", "", "path to a chrome devtools recorder json login flow to replay for auto-login"),
|
||||
flagSet.BoolVarP(&options.AuthCapture, "auth-capture", "ac", false, "open a browser to log in manually once, then capture the session for the scan"),
|
||||
flagSet.StringVarP(&options.AuthReauthStatusCodes, "auth-reauth-status-codes", "arsc", "", "comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan"),
|
||||
flagSet.StringVarP(&options.AuthRefreshInterval, "auth-refresh-interval", "ari", "", "re-run the auto-login once the captured session is older than this duration (e.g. 15m)"),
|
||||
)
|
||||
|
||||
flagSet.SetCustomHelpText(`EXAMPLES:
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
package fuzzplayground
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// This file turns the playground into a small but realistic "modern app" auth
|
||||
// test bench. It serves a range of login styles seen in the wild — classic
|
||||
// server-rendered forms, JS/SPA-rendered forms, username-first multi-step flows,
|
||||
// SPA logins that stash a JWT in web storage, CSRF-protected forms, a
|
||||
// disabled-until-valid submit button (a real regression class), and an
|
||||
// OAuth-style redirect dance — plus protected endpoints (whoami) and an
|
||||
// authenticated, fuzzable endpoint. It lets us exercise the auto-login /
|
||||
// recording / capture engine and authenticated scanning entirely in-repo,
|
||||
// without external apps.
|
||||
//
|
||||
// Credentials are fixed and shared across every style.
|
||||
const (
|
||||
AuthUsername = "[email protected]"
|
||||
AuthPassword = "nuclei-rocks"
|
||||
authSessionCookie = "PSESSION"
|
||||
)
|
||||
|
||||
// authStore is a tiny in-memory session/CSRF store for the auth playground.
|
||||
type authStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]string // session token -> username
|
||||
csrf map[string]struct{}
|
||||
}
|
||||
|
||||
func newAuthStore() *authStore {
|
||||
// Pre-seed the SPA-token style's fixed session so the JWT it stashes in web
|
||||
// storage also authenticates protected APIs (bearer path).
|
||||
return &authStore{
|
||||
sessions: map[string]string{"spa-static-session": AuthUsername},
|
||||
csrf: map[string]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// jwtFor returns a JWT-shaped token (eyJ...) so token-regex extraction has a
|
||||
// realistic target. It is not a real signed JWT; the playground treats the
|
||||
// session token embedded in it as the source of truth.
|
||||
func jwtFor(session string) string {
|
||||
// header.payload.signature where payload carries the opaque session id
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
|
||||
hex.EncodeToString([]byte(session)) + ".c2lnbmF0dXJl"
|
||||
}
|
||||
|
||||
func (s *authStore) issue(username string) (session, jwt string) {
|
||||
session = randToken()
|
||||
s.mu.Lock()
|
||||
s.sessions[session] = username
|
||||
s.mu.Unlock()
|
||||
return session, jwtFor(session)
|
||||
}
|
||||
|
||||
func (s *authStore) userFor(session string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.sessions[session]
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *authStore) issueCSRF() string {
|
||||
tok := randToken()
|
||||
s.mu.Lock()
|
||||
s.csrf[tok] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *authStore) consumeCSRF(tok string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.csrf[tok]; ok {
|
||||
delete(s.csrf, tok)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticatedUser resolves the caller's identity from either the session
|
||||
// cookie or a bearer token (the JWT carries the hex-encoded session id).
|
||||
func (s *authStore) authenticatedUser(r *http.Request) (string, bool) {
|
||||
if c, err := r.Cookie(authSessionCookie); err == nil && c.Value != "" {
|
||||
if u, ok := s.userFor(c.Value); ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
if sess := sessionFromJWT(strings.TrimPrefix(auth, "Bearer ")); sess != "" {
|
||||
if u, ok := s.userFor(sess); ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sessionFromJWT extracts the opaque session id embedded in a playground JWT.
|
||||
func sessionFromJWT(token string) string {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
raw, err := hex.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, session string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authSessionCookie,
|
||||
Value: session,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
// registerAuthRoutes wires every login style and the protected endpoints onto a
|
||||
// per-server auth store.
|
||||
func registerAuthRoutes(mux *http.ServeMux) {
|
||||
st := newAuthStore()
|
||||
|
||||
// 1. Classic server-rendered form login (works with the static HTTP engine).
|
||||
mux.HandleFunc("GET /auth/form-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, formLoginPage("", ""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/form-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, formLoginPage(r.FormValue("username"), "Invalid credentials"))
|
||||
})
|
||||
|
||||
// 2. CSRF-protected server-rendered form: a hidden token must be echoed back.
|
||||
mux.HandleFunc("GET /auth/csrf-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), ""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/csrf-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !st.consumeCSRF(r.FormValue("csrf_token")) {
|
||||
writeHTML(w, http.StatusForbidden, csrfLoginPage(st.issueCSRF(), "Invalid CSRF token"))
|
||||
return
|
||||
}
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), "Invalid credentials"))
|
||||
})
|
||||
|
||||
// 3. JS/SPA-rendered form: the raw HTML has no <form>; JS builds it and posts
|
||||
// via fetch. Only a real browser can log in here.
|
||||
mux.HandleFunc("GET /auth/spa-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
session, jwt := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"token": jwt})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// 4. Username-first multi-step flow (Google/Microsoft-style): password field
|
||||
// is revealed only after the "Next" button.
|
||||
mux.HandleFunc("GET /auth/multistep-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, multiStepLoginPage)
|
||||
})
|
||||
|
||||
// 5. Disabled-until-valid submit button — a real regression class: the submit
|
||||
// stays disabled until JS marks the form valid, so a naive immediate click
|
||||
// hangs. The engine must settle/fall back to Enter.
|
||||
mux.HandleFunc("GET /auth/strict-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, strictLoginPage)
|
||||
})
|
||||
|
||||
// 6. SPA login that stores a JWT in localStorage (no cookie at all).
|
||||
mux.HandleFunc("GET /auth/spa-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaTokenLoginPage)
|
||||
})
|
||||
|
||||
// 7. OAuth-style redirect flow: authorize -> (auto consent) -> callback sets
|
||||
// the session and redirects to the dashboard.
|
||||
mux.HandleFunc("GET /auth/oauth/authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/auth/oauth/callback?code="+randToken(), http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("GET /auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("code") == "" {
|
||||
writeString(w, http.StatusBadRequest, "missing code")
|
||||
return
|
||||
}
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
})
|
||||
|
||||
// 8. Multi-form page: a decoy search form (no password) precedes the real
|
||||
// login form. The detector must score forms and pick the credential one
|
||||
// rather than blindly taking the first <form>.
|
||||
mux.HandleFunc("GET /auth/multiform-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, multiFormLoginPage)
|
||||
})
|
||||
|
||||
// 9. JS-set cookie: login succeeds via XHR that returns the session id in the
|
||||
// body (no Set-Cookie), and client JS writes document.cookie. The headless
|
||||
// engine must capture cookies from the browser jar, not just Set-Cookie.
|
||||
mux.HandleFunc("GET /auth/jscookie-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, jsCookieLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login-jsbody", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
// Deliberately no Set-Cookie: the client sets the cookie via JS.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"session": session})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// 10. Delayed render: the login form is injected after an async tick, so an
|
||||
// engine that probes for the password field immediately would miss it and
|
||||
// must wait for it to appear.
|
||||
mux.HandleFunc("GET /auth/delayed-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, delayedLoginPage)
|
||||
})
|
||||
|
||||
// 11. Header-token login: a server-rendered form whose POST returns the
|
||||
// session token in a response header (no cookie), the token-in-header
|
||||
// API style. The engine must read the token from the header.
|
||||
mux.HandleFunc("GET /auth/header-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, headerTokenLoginPage(""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/header-token-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
_, jwt := st.issue(AuthUsername)
|
||||
// Token in a header, deliberately no Set-Cookie.
|
||||
w.Header().Set("X-Auth-Token", jwt)
|
||||
writeHTML(w, http.StatusOK, `<html><head><title>Dashboard</title></head><body><h1>Welcome, signed in</h1></body></html>`)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, headerTokenLoginPage("Invalid credentials"))
|
||||
})
|
||||
|
||||
// 12. SPA whose XHR login returns the token only in a response header (no
|
||||
// cookie, no body/storage token). Exercises the headless engine's
|
||||
// passive response-header interception.
|
||||
mux.HandleFunc("GET /auth/spa-header-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaHeaderTokenLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login-header", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
_, jwt := st.issue(AuthUsername)
|
||||
w.Header().Set("X-Auth-Token", jwt)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// Protected landing page: the login-success heuristic relies on the final
|
||||
// page having no password field, which this page satisfies.
|
||||
mux.HandleFunc("GET /auth/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := st.authenticatedUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(`<html><head><title>Dashboard</title></head>
|
||||
<body><h1>Welcome, %s</h1><a href="/auth/logout">Logout</a></body></html>`, html.EscapeString(user)))
|
||||
})
|
||||
|
||||
// whoami: protected API used to assert a captured session authenticates.
|
||||
mux.HandleFunc("GET /auth/whoami", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := st.authenticatedUser(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"user": user})
|
||||
})
|
||||
|
||||
// Authenticated, fuzzable endpoint: only reachable with a valid session, and
|
||||
// reflects q without sanitization (reflected XSS) so authenticated fuzzing
|
||||
// has a genuine target behind the login wall.
|
||||
mux.HandleFunc("GET /auth/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := st.authenticatedUser(r); !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf("<html><body><div>results for: %s</div></body></html>", q))
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if ck, err := r.Cookie(authSessionCookie); err == nil {
|
||||
st.mu.Lock()
|
||||
delete(st.sessions, ck.Value)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
setSessionCookie(w, "")
|
||||
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Login page templates --------------------------------------------------
|
||||
|
||||
func formLoginPage(username, errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" placeholder="Email" value="%s">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner, html.EscapeString(username))
|
||||
}
|
||||
|
||||
func csrfLoginPage(csrf, errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/csrf-login">
|
||||
<input type="hidden" name="csrf_token" value="%s">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner, html.EscapeString(csrf))
|
||||
}
|
||||
|
||||
// spaLoginPage builds the form entirely in JS (no <form> in the raw HTML) and
|
||||
// submits it via fetch, then redirects on success.
|
||||
const spaLoginPage = `<html><head><title>SPA Login</title></head><body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
var app = document.getElementById('app');
|
||||
var email = document.createElement('input'); email.id = 'email'; email.type = 'email';
|
||||
var pass = document.createElement('input'); pass.id = 'password'; pass.type = 'password';
|
||||
var btn = document.createElement('button'); btn.id = 'submit'; btn.textContent = 'Sign in';
|
||||
app.appendChild(email); app.appendChild(pass); app.appendChild(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
fetch('/auth/api/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: email.value, password: pass.value})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.token) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
|
||||
else { document.body.innerHTML += '<p>login failed</p>'; }
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// multiStepLoginPage hides the password field until "Next" is clicked.
|
||||
const multiStepLoginPage = `<html><head><title>Multi-step Login</title></head><body>
|
||||
<form id="f" method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" id="username" placeholder="Email">
|
||||
<button type="button" id="next">Next</button>
|
||||
<div id="pwwrap" style="display:none">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('next').addEventListener('click', function () {
|
||||
document.getElementById('pwwrap').style.display = 'block';
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// strictLoginPage keeps the submit button disabled until both fields are
|
||||
// non-empty, reproducing the disabled-until-valid regression class.
|
||||
const strictLoginPage = `<html><head><title>Strict Login</title></head><body>
|
||||
<form method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" id="username" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit" disabled>Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
var u = document.getElementById('username'), p = document.getElementById('password'), b = document.getElementById('submit');
|
||||
function validate() { b.disabled = !(u.value.length > 0 && p.value.length > 0); }
|
||||
u.addEventListener('input', validate); p.addEventListener('input', validate);
|
||||
</script></body></html>`
|
||||
|
||||
// spaTokenLoginPage validates client-side and stores a JWT in localStorage with
|
||||
// no cookie, the canonical token-in-web-storage case.
|
||||
var spaTokenLoginPage = fmt.Sprintf(`<html><head><title>SPA Token Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var u = document.getElementById('email').value;
|
||||
var p = document.getElementById('password').value;
|
||||
if (u === %q && p === %q) {
|
||||
window.localStorage.setItem('access_token', %q);
|
||||
window.sessionStorage.setItem('uid', '1');
|
||||
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>login failed</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`, AuthUsername, AuthPassword, jwtFor("spa-static-session"))
|
||||
|
||||
// multiFormLoginPage places a decoy search form (no password field) before the
|
||||
// real login form to exercise best-form selection over first-form selection.
|
||||
const multiFormLoginPage = `<html><head><title>Multi-form Login</title></head><body>
|
||||
<form id="search" method="get" action="/auth/api/search">
|
||||
<input type="text" name="q" placeholder="Search">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<hr>
|
||||
<form id="login" method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`
|
||||
|
||||
// jsCookieLoginPage logs in via XHR and sets the session cookie from JS
|
||||
// (document.cookie) rather than relying on a Set-Cookie response header.
|
||||
const jsCookieLoginPage = `<html><head><title>JS Cookie Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
fetch('/auth/api/login-jsbody', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.session) {
|
||||
document.cookie = 'PSESSION=' + d.session + '; path=/';
|
||||
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>login failed</p>';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// headerTokenLoginPage is a classic server-rendered form whose POST returns the
|
||||
// session token in a response header rather than a cookie or body.
|
||||
func headerTokenLoginPage(errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Header Token Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/header-token-login">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner)
|
||||
}
|
||||
|
||||
// spaHeaderTokenLoginPage logs in via XHR; the token comes back only in a
|
||||
// response header (the page never reads or stores it), so the session is
|
||||
// recoverable only by observing response headers.
|
||||
const spaHeaderTokenLoginPage = `<html><head><title>SPA Header Token Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
fetch('/auth/api/login-header', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.ok) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
|
||||
else { document.body.innerHTML += '<p>login failed</p>'; }
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// delayedLoginPage injects the login form only after an async tick, simulating a
|
||||
// SPA that renders its form after bootstrapping.
|
||||
const delayedLoginPage = `<html><head><title>Delayed Login</title></head><body>
|
||||
<div id="app"><p>loading...</p></div>
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
document.getElementById('app').innerHTML =
|
||||
'<form method="post" action="/auth/form-login">' +
|
||||
'<input type="text" name="username" placeholder="Email">' +
|
||||
'<input type="password" name="password" placeholder="Password">' +
|
||||
'<button type="submit">Sign in</button></form>';
|
||||
}, 700);
|
||||
</script></body></html>`
|
||||
@@ -39,6 +39,8 @@ func GetPlaygroundServer() *PlaygroundServer {
|
||||
mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
|
||||
mux.HandleFunc("GET /blog/posts", getPostsHandler)
|
||||
|
||||
registerAuthRoutes(mux)
|
||||
|
||||
handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
|
||||
return &PlaygroundServer{handler: handler}
|
||||
}
|
||||
@@ -284,6 +286,12 @@ func writeJSON(w http.ResponseWriter, statusCode int, value interface{}) {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeString(w http.ResponseWriter, statusCode int, value string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_, _ = io.WriteString(w, value)
|
||||
}
|
||||
|
||||
func recoverPlaygroundRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
@@ -16,6 +22,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
||||
httpprotocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
@@ -71,6 +78,265 @@ func GetAuthTmplStore(opts *types.Options, catalog catalog.Catalog, execOpts *pr
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// captureOnceStoreFromOptions launches a visible browser at -auth-login-url,
|
||||
// lets the user log in manually (pressing Enter in the terminal when done) and
|
||||
// builds an in-memory static auth store from the captured session (cookies and
|
||||
// an optional token), scoped to the login host. Unlike auto-login this captures
|
||||
// a point-in-time session with no automated re-authentication.
|
||||
func captureOnceStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
|
||||
u, err := url.Parse(opts.AuthLoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "invalid -auth-login-url")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errkit.New("capture-once: -auth-login-url is required and must include a host")
|
||||
}
|
||||
|
||||
rt := buildAutoLoginRuntimeOptions(opts)
|
||||
cfg := autologin.Config{
|
||||
LoginURL: opts.AuthLoginURL,
|
||||
Proxy: rt.Proxy,
|
||||
CDPEndpoint: rt.CDPEndpoint,
|
||||
UseInstalledChrome: rt.UseInstalledChrome,
|
||||
UserAgent: rt.UserAgent,
|
||||
CustomHeaders: rt.CustomHeaders,
|
||||
}
|
||||
|
||||
ready := func(ctx context.Context) error {
|
||||
fmt.Fprintf(os.Stderr, "[auth-capture] Log in to %s in the opened browser window, then press Enter here to capture the session...", opts.AuthLoginURL)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, rerr := reader.ReadString('\n')
|
||||
done <- rerr
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case rerr := <-done:
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
|
||||
session, err := autologin.CaptureOnce(context.Background(), cfg, ready)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return captureSessionToStore(session, u.Host)
|
||||
}
|
||||
|
||||
// captureSessionToStore converts a captured login session into a static auth
|
||||
// store scoped to host: cookies as a Cookie secret, an extracted token as a
|
||||
// BearerToken secret, and any web storage as a WebStorage secret (replayed into
|
||||
// headless scan pages). It is split out from captureOnceStoreFromOptions so the
|
||||
// session->store mapping is unit-testable without driving a browser.
|
||||
func captureSessionToStore(session *autologin.Session, host string) (*authx.Authx, error) {
|
||||
if session == nil {
|
||||
return nil, errkit.New("capture-once: nil session")
|
||||
}
|
||||
var secrets []authx.Secret
|
||||
if len(session.Cookies) > 0 {
|
||||
cookies := make([]authx.Cookie, 0, len(session.Cookies))
|
||||
domains := []string{host}
|
||||
for _, c := range session.Cookies {
|
||||
cookies = append(cookies, authx.Cookie{
|
||||
Key: c.Name,
|
||||
Value: c.Value,
|
||||
Domain: c.Domain,
|
||||
Path: c.Path,
|
||||
Secure: c.Secure,
|
||||
})
|
||||
if d := strings.TrimPrefix(strings.TrimSpace(c.Domain), "."); d != "" && !strings.EqualFold(d, host) {
|
||||
found := false
|
||||
for _, existing := range domains {
|
||||
if strings.EqualFold(existing, d) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.CookiesAuth),
|
||||
Domains: domains,
|
||||
Cookies: cookies,
|
||||
})
|
||||
}
|
||||
if session.Token != "" {
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.BearerTokenAuth),
|
||||
Domains: []string{host},
|
||||
Token: session.Token,
|
||||
})
|
||||
}
|
||||
if len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0 {
|
||||
// Carry captured web storage so a token-in-web-storage SPA session
|
||||
// replays into headless scan pages (HTTP scans ignore this secret).
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.WebStorageAuth),
|
||||
Domains: []string{host},
|
||||
LocalStorage: session.LocalStorage,
|
||||
SessionStorage: session.SessionStorage,
|
||||
})
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
return nil, errkit.New("capture-once: captured session had no replayable cookies, token or web storage")
|
||||
}
|
||||
for i := range secrets {
|
||||
if err := secrets[i].Validate(); err != nil {
|
||||
return nil, errkit.Wrap(err, "capture-once: invalid captured secret")
|
||||
}
|
||||
}
|
||||
return &authx.Authx{ID: "cli-capture-once", Secrets: secrets}, nil
|
||||
}
|
||||
|
||||
// buildAutoLoginRuntimeOptions maps scan-level options into the auto-login
|
||||
// runtime options so a (headless) auto-login uses the same identity and network
|
||||
// path as the scan: user-agent and custom headers (-H), proxy, CDP endpoint and
|
||||
// Chrome settings.
|
||||
func buildAutoLoginRuntimeOptions(opts *types.Options) *authx.AutoLoginRuntimeOptions {
|
||||
// Mirror the proxy precedence used everywhere else in the codebase (HTTP
|
||||
// proxy first, SOCKS as fallback) so a SOCKS-proxied scan does not silently
|
||||
// bypass the proxy during auto-login.
|
||||
proxy := opts.AliveHttpProxy
|
||||
if proxy == "" {
|
||||
proxy = opts.AliveSocksProxy
|
||||
}
|
||||
rt := &authx.AutoLoginRuntimeOptions{
|
||||
Proxy: proxy,
|
||||
CDPEndpoint: opts.CDPEndpoint,
|
||||
UseInstalledChrome: opts.UseInstalledChrome,
|
||||
ShowBrowser: opts.ShowBrowser,
|
||||
}
|
||||
for _, header := range opts.CustomHeaders {
|
||||
parts := strings.SplitN(header, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(key, "User-Agent") {
|
||||
rt.UserAgent = value
|
||||
continue
|
||||
}
|
||||
if rt.CustomHeaders == nil {
|
||||
rt.CustomHeaders = map[string]string{}
|
||||
}
|
||||
rt.CustomHeaders[key] = value
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// autoLoginHTTPClient returns the nuclei HTTP client of the scan's execution
|
||||
// id. The HTTP auto-login reuses its transport, so the login requests go
|
||||
// through nuclei's dialer like every scan request: network policy (-lna,
|
||||
// excluded hosts), resolvers and proxy. autologin.Login still uses its own
|
||||
// cookie jar and cross-origin redirect guard on top of that transport.
|
||||
func autoLoginHTTPClient(opts *types.Options) (*http.Client, error) {
|
||||
client, err := httpclientpool.Get(opts, &httpclientpool.Configuration{}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client == nil || client.HTTPClient == nil {
|
||||
return nil, errkit.New("auto-login: nuclei http client is not available")
|
||||
}
|
||||
return client.HTTPClient, nil
|
||||
}
|
||||
|
||||
// autoLoginStoreFromOptions assembles an in-memory Authx store containing a
|
||||
// single auto-login dynamic secret built from the -auth-login-url flag set. The
|
||||
// captured session is scoped to the login URL's host.
|
||||
func autoLoginStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
|
||||
username, password := authCredentialsFromOptions(opts)
|
||||
// The login URL may be supplied directly (-auth-login-url) or derived from a
|
||||
// recording's first navigate step; resolve the host scope from whichever is
|
||||
// available.
|
||||
loginURL := opts.AuthLoginURL
|
||||
if loginURL == "" && opts.AuthRecording != "" {
|
||||
steps, err := autologin.StepsFromRecordingFile(opts.AuthRecording, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginURL = autologin.FirstNavigateURL(steps)
|
||||
}
|
||||
u, err := url.Parse(loginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "invalid auto-login url")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errkit.New("auto-login: could not determine host (set -auth-login-url or provide a recording with a navigate step)")
|
||||
}
|
||||
reauthCodes, err := parseStatusCodes(opts.AuthReauthStatusCodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authx.Authx{
|
||||
ID: "cli-auto-login",
|
||||
Dynamic: []authx.Dynamic{
|
||||
{
|
||||
Secret: &authx.Secret{Domains: []string{u.Host}},
|
||||
// Session lifecycle: re-run the login on the configured expiry
|
||||
// status codes and/or once the session ages past the interval, so
|
||||
// a long scan does not silently continue with a dead session.
|
||||
RefreshInterval: opts.AuthRefreshInterval,
|
||||
ReauthStatusCodes: reauthCodes,
|
||||
AutoLogin: &authx.AutoLoginConfig{
|
||||
LoginURL: loginURL,
|
||||
Username: username,
|
||||
Password: password,
|
||||
UsernameField: opts.AuthUsernameField,
|
||||
PasswordField: opts.AuthPasswordField,
|
||||
Headless: opts.AuthHeadless,
|
||||
Recording: opts.AuthRecording,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authCredentialsFromOptions resolves CLI credentials, preferring flag values
|
||||
// and falling back to NUCLEI_AUTH_PASSWORD so the password need not appear on
|
||||
// the process argv / shell history.
|
||||
func authCredentialsFromOptions(opts *types.Options) (username, password string) {
|
||||
username = opts.AuthUsername
|
||||
password = opts.AuthPassword
|
||||
if password == "" {
|
||||
password = os.Getenv("NUCLEI_AUTH_PASSWORD")
|
||||
}
|
||||
return username, password
|
||||
}
|
||||
|
||||
// parseStatusCodes parses a comma-separated list of HTTP status codes (e.g.
|
||||
// "401,403") into a slice of ints. An empty string yields a nil slice.
|
||||
func parseStatusCodes(raw string) ([]int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var codes []int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
code, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrapf(err, "invalid -auth-reauth-status-codes value %q", part)
|
||||
}
|
||||
if code < 100 || code > 599 {
|
||||
return nil, errkit.Newf("invalid -auth-reauth-status-codes value %q: must be a valid HTTP status code", part)
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// GetLazyAuthFetchCallback returns a lazy fetch callback for auth secrets
|
||||
func GetLazyAuthFetchCallback(opts *AuthLazyFetchOptions) authx.LazyFetchSecret {
|
||||
return func(d *authx.Dynamic) error {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCaptureSessionToStore(t *testing.T) {
|
||||
session := &autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "sess-dave"}},
|
||||
Token: "eyJ.payload.sig",
|
||||
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
store, err := captureSessionToStore(session, "app.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Secrets, 3, "cookies, token and web storage must each map to a secret")
|
||||
|
||||
byType := map[string]authx.Secret{}
|
||||
for _, s := range store.Secrets {
|
||||
byType[s.Type] = s
|
||||
require.Equal(t, []string{"app.example.com"}, s.Domains, "every secret is scoped to the login host")
|
||||
require.NoError(t, s.Validate())
|
||||
}
|
||||
|
||||
require.Equal(t, "session", byType[string(authx.CookiesAuth)].Cookies[0].Key)
|
||||
require.Equal(t, "eyJ.payload.sig", byType[string(authx.BearerTokenAuth)].Token)
|
||||
|
||||
ws := byType[string(authx.WebStorageAuth)]
|
||||
require.Equal(t, "eyJ.payload.sig", ws.LocalStorage["jwt"])
|
||||
require.Equal(t, "tok", ws.SessionStorage["csrf"])
|
||||
|
||||
// The web storage secret must surface as a BrowserStorageProvider so the
|
||||
// headless engine can seed it into scanned pages.
|
||||
bsp, ok := ws.GetStrategy().(authx.BrowserStorageProvider)
|
||||
require.True(t, ok)
|
||||
local, sess := bsp.WebStorage()
|
||||
require.Equal(t, "eyJ.payload.sig", local["jwt"])
|
||||
require.Equal(t, "tok", sess["csrf"])
|
||||
}
|
||||
|
||||
func TestCaptureSessionToStore_StorageOnly(t *testing.T) {
|
||||
// A pure token-in-localStorage SPA: no cookies, no extracted token, only
|
||||
// web storage — it must still produce a usable store.
|
||||
session := &autologin.Session{LocalStorage: map[string]string{"jwt": "x"}}
|
||||
store, err := captureSessionToStore(session, "spa.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Secrets, 1)
|
||||
require.Equal(t, string(authx.WebStorageAuth), store.Secrets[0].Type)
|
||||
}
|
||||
|
||||
func TestCaptureSessionToStore_Empty(t *testing.T) {
|
||||
_, err := captureSessionToStore(&autologin.Session{}, "app.example.com")
|
||||
require.Error(t, err, "an empty session must not produce a store")
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_RecordingDerivesHost(t *testing.T) {
|
||||
recording := `{"steps": [
|
||||
{"type": "navigate", "url": "https://recorded.example.com/signin"},
|
||||
{"type": "change", "value": "bob", "selectors": [["#user"]]}
|
||||
]}`
|
||||
path := filepath.Join(t.TempDir(), "flow.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
|
||||
|
||||
// No -auth-login-url: the host scope must come from the recording.
|
||||
opts := &types.Options{AuthRecording: path, AuthUsername: "bob", AuthPassword: "p"}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Dynamic, 1)
|
||||
require.Equal(t, []string{"recorded.example.com"}, store.Dynamic[0].Domains)
|
||||
require.Equal(t, path, store.Dynamic[0].AutoLogin.Recording)
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
AuthLoginURL: "https://app.example.com:8443/login",
|
||||
AuthUsername: "alice",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthUsernameField: "email",
|
||||
AuthHeadless: true,
|
||||
}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Dynamic, 1)
|
||||
|
||||
dyn := store.Dynamic[0]
|
||||
require.NotNil(t, dyn.Secret)
|
||||
require.Equal(t, []string{"app.example.com:8443"}, dyn.Domains, "session should be scoped to the login host")
|
||||
require.NotNil(t, dyn.AutoLogin)
|
||||
require.Equal(t, "https://app.example.com:8443/login", dyn.AutoLogin.LoginURL)
|
||||
require.Equal(t, "alice", dyn.AutoLogin.Username)
|
||||
require.Equal(t, "email", dyn.AutoLogin.UsernameField)
|
||||
require.True(t, dyn.AutoLogin.Headless)
|
||||
|
||||
// The assembled store must validate as a real auto-login dynamic.
|
||||
require.NoError(t, dyn.Validate())
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_InvalidURL(t *testing.T) {
|
||||
_, err := autoLoginStoreFromOptions(&types.Options{AuthLoginURL: "://nohost"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_SessionLifecycle(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
AuthLoginURL: "https://app.example.com/login",
|
||||
AuthUsername: "alice",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthReauthStatusCodes: "401, 403",
|
||||
AuthRefreshInterval: "15m",
|
||||
}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
dyn := store.Dynamic[0]
|
||||
require.Equal(t, []int{401, 403}, dyn.ReauthStatusCodes, "reauth status codes must be threaded onto the dynamic")
|
||||
require.Equal(t, "15m", dyn.RefreshInterval)
|
||||
// The lifecycle-configured store must validate (refresh-interval is parsed).
|
||||
require.NoError(t, dyn.Validate())
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_InvalidReauthCodes(t *testing.T) {
|
||||
_, err := autoLoginStoreFromOptions(&types.Options{
|
||||
AuthLoginURL: "https://app.example.com/login",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthReauthStatusCodes: "401,nope",
|
||||
})
|
||||
require.Error(t, err, "non-numeric reauth status code must be rejected")
|
||||
}
|
||||
|
||||
func TestParseStatusCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []int
|
||||
wantErr bool
|
||||
}{
|
||||
{"", nil, false},
|
||||
{" ", nil, false},
|
||||
{"401", []int{401}, false},
|
||||
{"401,403, 419", []int{401, 403, 419}, false},
|
||||
{"401,,403", []int{401, 403}, false},
|
||||
{"abc", nil, true},
|
||||
{"99", nil, true},
|
||||
{"600", nil, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseStatusCodes(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err, "input %q", tc.in)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, "input %q", tc.in)
|
||||
require.Equal(t, tc.want, got, "input %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAutoLoginRuntimeOptions(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
CustomHeaders: []string{"User-Agent: NucleiScan/1.0", "X-Env: staging", "malformed-header"},
|
||||
AliveHttpProxy: "http://127.0.0.1:8080",
|
||||
CDPEndpoint: "ws://127.0.0.1:9222",
|
||||
UseInstalledChrome: true,
|
||||
ShowBrowser: true,
|
||||
}
|
||||
rt := buildAutoLoginRuntimeOptions(opts)
|
||||
require.Equal(t, "NucleiScan/1.0", rt.UserAgent, "User-Agent should be split out of custom headers")
|
||||
require.Equal(t, "staging", rt.CustomHeaders["X-Env"])
|
||||
require.NotContains(t, rt.CustomHeaders, "User-Agent", "UA must not be duplicated into custom headers")
|
||||
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
|
||||
require.Equal(t, "ws://127.0.0.1:9222", rt.CDPEndpoint)
|
||||
require.True(t, rt.UseInstalledChrome)
|
||||
require.True(t, rt.ShowBrowser)
|
||||
}
|
||||
|
||||
func TestBuildAutoLoginRuntimeOptions_SocksProxyFallback(t *testing.T) {
|
||||
// With only a SOCKS proxy configured, the auto-login must still pick it up
|
||||
// (regression: previously only AliveHttpProxy was threaded).
|
||||
rt := buildAutoLoginRuntimeOptions(&types.Options{
|
||||
AliveSocksProxy: "socks5://127.0.0.1:1080",
|
||||
})
|
||||
require.Equal(t, "socks5://127.0.0.1:1080", rt.Proxy)
|
||||
|
||||
// HTTP proxy takes precedence when both are set.
|
||||
rt = buildAutoLoginRuntimeOptions(&types.Options{
|
||||
AliveHttpProxy: "http://127.0.0.1:8080",
|
||||
AliveSocksProxy: "socks5://127.0.0.1:1080",
|
||||
})
|
||||
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
|
||||
}
|
||||
|
||||
// The HTTP auto-login must go through nuclei's dialer, so -lna refuses a login
|
||||
// page on the loopback network and the server never sees a request.
|
||||
func TestAutoLoginHTTPClientHonorsNetworkPolicy(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write([]byte(`<form method="post" action="/login"><input name="user"><input type="password" name="pass"></form>`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
options := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
Timeout: 5,
|
||||
RestrictLocalNetworkAccess: true,
|
||||
}
|
||||
require.NoError(t, protocolstate.Init(options))
|
||||
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
|
||||
|
||||
client, err := autoLoginHTTPClient(options)
|
||||
require.NoError(t, err)
|
||||
_, err = autologin.Login(context.Background(), client, autologin.Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
})
|
||||
require.Error(t, err, "the login page on loopback must be refused under -lna")
|
||||
require.Zero(t, hits.Load(), "the local server must get no request")
|
||||
}
|
||||
|
||||
func TestAutoLoginHTTPClientNeedsDialers(t *testing.T) {
|
||||
_, err := autoLoginHTTPClient(&types.Options{ExecutionId: t.Name()})
|
||||
require.Error(t, err, "without nuclei dialers there is no policy-bound client")
|
||||
}
|
||||
+83
-15
@@ -114,6 +114,7 @@ type Runner struct {
|
||||
httpApiEndpoint *httpapi.Server
|
||||
fuzzStats *fuzzStats.Tracker
|
||||
dastServer *server.DASTServer
|
||||
authProvider authprovider.AuthProvider
|
||||
llmClient llmclient.Client
|
||||
}
|
||||
|
||||
@@ -535,6 +536,11 @@ func (r *Runner) Close() {
|
||||
if r.browser != nil {
|
||||
r.browser.Close()
|
||||
}
|
||||
// Drop any in-memory auto-login session so nuclei does not retain live
|
||||
// session material after the scan (server-side sessions expire on their own).
|
||||
if r.authProvider != nil {
|
||||
r.authProvider.Close()
|
||||
}
|
||||
if r.tmpDir != "" {
|
||||
_ = os.RemoveAll(r.tmpDir)
|
||||
}
|
||||
@@ -695,24 +701,86 @@ func (r *Runner) RunEnumeration() error {
|
||||
executorOpts.ExportReqURLPattern = true
|
||||
}
|
||||
|
||||
if len(r.options.SecretsFile) > 0 && !r.options.Validate {
|
||||
// Clone options so GetAuthTmplStore can modify them without affecting the original
|
||||
authOptions := r.options.Copy()
|
||||
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
|
||||
if (len(r.options.SecretsFile) > 0 || r.options.AuthLoginURL != "" || r.options.AuthRecording != "" || r.options.AuthCapture) && !r.options.Validate {
|
||||
autoLoginOpts := buildAutoLoginRuntimeOptions(r.options)
|
||||
autoLoginClient, err := autoLoginHTTPClient(r.options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load dynamic auth templates")
|
||||
return errors.Wrap(err, "could not create auto-login http client")
|
||||
}
|
||||
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
|
||||
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
|
||||
TemplateStore: authTmplStore,
|
||||
ExecOpts: executorOpts,
|
||||
})
|
||||
// initialize auth provider
|
||||
provider, err := authprovider.NewAuthProvider(authOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auth provider")
|
||||
autoLoginOpts.HTTPClient = autoLoginClient
|
||||
var providers []authprovider.AuthProvider
|
||||
|
||||
if len(r.options.SecretsFile) > 0 {
|
||||
// Clone options so GetAuthTmplStore can modify them without affecting the original
|
||||
authOptions := r.options.Copy()
|
||||
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load dynamic auth templates")
|
||||
}
|
||||
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
|
||||
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
|
||||
TemplateStore: authTmplStore,
|
||||
ExecOpts: executorOpts,
|
||||
})
|
||||
authOpts.AutoLoginOptions = autoLoginOpts
|
||||
fileProvider, err := authprovider.NewAuthProvider(authOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auth provider")
|
||||
}
|
||||
providers = append(providers, fileProvider)
|
||||
}
|
||||
executorOpts.AuthProvider = provider
|
||||
|
||||
// Turnkey auto-login from CLI flags (-auth-login-url): build an in-memory
|
||||
// auto-login secret scoped to the login host, no secrets file needed.
|
||||
// Capture-once also requires -auth-login-url, so skip the automated
|
||||
// provider there to avoid overlapping sessions/logins.
|
||||
if !r.options.AuthCapture && (r.options.AuthLoginURL != "" || r.options.AuthRecording != "") {
|
||||
engine := "http"
|
||||
if r.options.AuthHeadless || r.options.AuthRecording != "" {
|
||||
engine = "headless"
|
||||
}
|
||||
target := r.options.AuthLoginURL
|
||||
if target == "" {
|
||||
target = r.options.AuthRecording
|
||||
}
|
||||
r.Logger.Info().Msgf("Auto-login enabled (%s engine) for %s", engine, target)
|
||||
store, err := autoLoginStoreFromOptions(r.options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not build auto-login auth provider")
|
||||
}
|
||||
cliProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auto-login auth provider")
|
||||
}
|
||||
providers = append(providers, cliProvider)
|
||||
}
|
||||
|
||||
// Capture-once: open a visible browser for a one-time manual login and
|
||||
// build a static session store from the captured cookies/token.
|
||||
if r.options.AuthCapture {
|
||||
if r.options.AuthLoginURL == "" {
|
||||
return errors.New("-auth-capture requires -auth-login-url (the page to open for manual login)")
|
||||
}
|
||||
r.Logger.Info().Msgf("Capture-once login enabled for %s", r.options.AuthLoginURL)
|
||||
store, err := captureOnceStoreFromOptions(r.options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not capture login session")
|
||||
}
|
||||
captureProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create capture-once auth provider")
|
||||
}
|
||||
providers = append(providers, captureProvider)
|
||||
}
|
||||
|
||||
switch len(providers) {
|
||||
case 1:
|
||||
executorOpts.AuthProvider = providers[0]
|
||||
default:
|
||||
executorOpts.AuthProvider = authprovider.NewMultiAuthProvider(providers...)
|
||||
}
|
||||
// Retained so Runner.Close can drop captured session material on exit.
|
||||
r.authProvider = executorOpts.AuthProvider
|
||||
}
|
||||
|
||||
if r.options.ShouldUseHostError() {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// AutoLoginConfig declares a turnkey, template-free authenticated-scanning
|
||||
// login. Instead of pointing at a hand-written auth template (Dynamic.TemplatePath),
|
||||
// the user supplies only a login URL and credentials; nuclei fetches the login
|
||||
// page, auto-detects the form (username/password fields plus hidden CSRF tokens)
|
||||
// and submits it, capturing the resulting session. The captured session is fed
|
||||
// into the same Dynamic state machine, so refresh-interval and
|
||||
// reauth-status-codes re-run the login automatically.
|
||||
type AutoLoginConfig struct {
|
||||
// LoginURL is the page serving the login form (required).
|
||||
LoginURL string `json:"login-url" yaml:"login-url"`
|
||||
// Username / Password are the credentials to submit. Username may be empty
|
||||
// for password-only forms.
|
||||
Username string `json:"username" yaml:"username"`
|
||||
Password string `json:"password" yaml:"password"`
|
||||
// UsernameField / PasswordField force the form field names when
|
||||
// auto-detection would guess wrong (optional).
|
||||
UsernameField string `json:"username-field" yaml:"username-field"`
|
||||
PasswordField string `json:"password-field" yaml:"password-field"`
|
||||
// ExtraFields are additional form values submitted verbatim (e.g. a tenant
|
||||
// id), overriding detected fields of the same name.
|
||||
ExtraFields []KV `json:"extra-fields" yaml:"extra-fields"`
|
||||
// TokenRegex, when set, extracts a bearer token (first capture group) from
|
||||
// the final response body for token-based logins that don't set a cookie.
|
||||
TokenRegex string `json:"token-regex" yaml:"token-regex"`
|
||||
// AllowCrossOriginAction lets the HTTP engine submit the credentials to a
|
||||
// login form whose action is on a different origin than LoginURL (e.g. an
|
||||
// SSO provider). Such a form is refused by default.
|
||||
AllowCrossOriginAction bool `json:"allow-cross-origin-action" yaml:"allow-cross-origin-action"`
|
||||
|
||||
// Headless drives a real browser for the login instead of the HTTP engine,
|
||||
// enabling JS-rendered / SPA / multi-step / SSO login pages.
|
||||
Headless bool `json:"headless" yaml:"headless"`
|
||||
// ShowBrowser runs the headless browser headful (debugging only).
|
||||
ShowBrowser bool `json:"show-browser" yaml:"show-browser"`
|
||||
// UseInstalledChrome forces a system-installed Chrome for the headless login.
|
||||
UseInstalledChrome bool `json:"use-installed-chrome" yaml:"use-installed-chrome"`
|
||||
// Proxy routes the headless login's browser traffic through this proxy URL.
|
||||
Proxy string `json:"proxy" yaml:"proxy"`
|
||||
// Steps, when set, drives an explicit multi-step headless login flow
|
||||
// (username-first / SSO / consent screens) instead of single-shot detection.
|
||||
Steps []autologin.LoginStep `json:"steps" yaml:"steps"`
|
||||
// Recording is a path to a Chrome DevTools Recorder / @puppeteer/replay JSON
|
||||
// export of a real login. It is compiled into Steps at validation time, so a
|
||||
// complex login can be authored by recording in a browser instead of writing
|
||||
// steps by hand. Implies a headless login. Recorded credential literals are
|
||||
// replaced with {{username}}/{{password}} placeholders.
|
||||
Recording string `json:"recording" yaml:"recording"`
|
||||
}
|
||||
|
||||
// Validate validates the auto-login configuration.
|
||||
func (a *AutoLoginConfig) Validate() error {
|
||||
// A recording compiles into Steps and drives a headless login. Load it first
|
||||
// so a missing/invalid recording fails fast and so it can supply the login
|
||||
// URL when one isn't given explicitly.
|
||||
if a.Recording != "" {
|
||||
if len(a.Steps) > 0 {
|
||||
return errkit.New("recording and steps are mutually exclusive for auto-login")
|
||||
}
|
||||
steps, err := autologin.StepsFromRecordingFile(a.Recording, a.Username, a.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Steps = steps
|
||||
a.Headless = true
|
||||
if a.LoginURL == "" {
|
||||
a.LoginURL = autologin.FirstNavigateURL(steps)
|
||||
}
|
||||
}
|
||||
if a.LoginURL == "" {
|
||||
return errkit.New("login-url is required for auto-login dynamic secret")
|
||||
}
|
||||
if a.Password == "" {
|
||||
return errkit.New("password is required for auto-login dynamic secret")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoLoginRuntimeOptions carries scan-level browser configuration (from
|
||||
// types.Options) into the auto-login engine so a headless login uses the same
|
||||
// identity (user-agent, headers), network path (proxy, CDP endpoint) and Chrome
|
||||
// settings as the scan. Per-secret YAML fields take precedence over these.
|
||||
type AutoLoginRuntimeOptions struct {
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
CustomHeaders map[string]string
|
||||
Proxy string
|
||||
CDPEndpoint string
|
||||
UseInstalledChrome bool
|
||||
ShowBrowser bool
|
||||
}
|
||||
|
||||
// SetAutoLoginCallback installs the fetch callback that performs the form-based
|
||||
// auto-login and renders the captured session into the secret. Unlike
|
||||
// SetLazyFetchCallback, it does not run the template-substitution wrapper: the
|
||||
// applied secret is built directly from the captured cookies/token, so it is
|
||||
// simply overwritten on each (re-)authentication.
|
||||
//
|
||||
// rt carries optional scan-level browser/runtime options (user-agent, headers,
|
||||
// proxy, CDP endpoint, HTTP client); pass nil for defaults.
|
||||
func (d *Dynamic) SetAutoLoginCallback(rt *AutoLoginRuntimeOptions) {
|
||||
if rt == nil {
|
||||
rt = &AutoLoginRuntimeOptions{}
|
||||
}
|
||||
d.autoLoginClient = rt.HTTPClient
|
||||
d.fetchCallback = func(d *Dynamic) error {
|
||||
if d.AutoLogin == nil {
|
||||
return errkit.New("auto-login callback invoked without auto-login config")
|
||||
}
|
||||
cfg := autologin.Config{
|
||||
LoginURL: d.AutoLogin.LoginURL,
|
||||
Username: d.AutoLogin.Username,
|
||||
Password: d.AutoLogin.Password,
|
||||
UsernameField: d.AutoLogin.UsernameField,
|
||||
PasswordField: d.AutoLogin.PasswordField,
|
||||
TokenRegex: d.AutoLogin.TokenRegex,
|
||||
ExtraFields: kvSliceToMap(d.AutoLogin.ExtraFields),
|
||||
AllowCrossOriginAction: d.AutoLogin.AllowCrossOriginAction,
|
||||
Headless: d.AutoLogin.Headless,
|
||||
ShowBrowser: d.AutoLogin.ShowBrowser || rt.ShowBrowser,
|
||||
UseInstalledChrome: d.AutoLogin.UseInstalledChrome || rt.UseInstalledChrome,
|
||||
Proxy: firstNonEmpty(d.AutoLogin.Proxy, rt.Proxy),
|
||||
CDPEndpoint: rt.CDPEndpoint,
|
||||
UserAgent: rt.UserAgent,
|
||||
CustomHeaders: rt.CustomHeaders,
|
||||
Steps: d.AutoLogin.Steps,
|
||||
}
|
||||
var (
|
||||
session *autologin.Session
|
||||
err error
|
||||
)
|
||||
engine := autoLoginEngineName(d.AutoLogin.Headless)
|
||||
if d.AutoLogin.Headless {
|
||||
// The browser engine runs JS, so it handles SPA / multi-step / SSO
|
||||
// login pages the HTTP form submitter cannot.
|
||||
session, err = autologin.LoginHeadless(context.Background(), cfg)
|
||||
} else {
|
||||
session, err = autologin.Login(context.Background(), d.autoLoginClient, cfg)
|
||||
}
|
||||
if err != nil {
|
||||
// Surface a clear, actionable failure at scan start (prefetch) and on
|
||||
// every lazy re-authentication.
|
||||
gologger.Warning().Msgf("auto-login (%s) failed for %s: %s", engine, cfg.LoginURL, err)
|
||||
return errkit.Wrapf(err, "auto-login (%s) failed for %s", engine, cfg.LoginURL)
|
||||
}
|
||||
|
||||
// Expose extracted values for observability / downstream templating.
|
||||
d.Extracted = map[string]interface{}{}
|
||||
if session.CookieHeader != "" {
|
||||
d.Extracted["cookie"] = session.CookieHeader
|
||||
}
|
||||
for _, c := range session.Cookies {
|
||||
d.Extracted[c.Name] = c.Value
|
||||
}
|
||||
if session.Token != "" {
|
||||
d.Extracted["token"] = session.Token
|
||||
}
|
||||
|
||||
if applyErr := d.applyAutoLoginSession(session); applyErr != nil {
|
||||
gologger.Warning().Msgf("auto-login (%s) for %s: %s", engine, cfg.LoginURL, applyErr)
|
||||
return errkit.Wrapf(applyErr, "auto-login (%s) for %s", engine, cfg.LoginURL)
|
||||
}
|
||||
gologger.Info().Msgf("auto-login (%s) succeeded for %s: %s", engine, cfg.LoginURL, summarizeSession(session))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// autoLoginEngineName returns a human-readable engine label for log messages.
|
||||
func autoLoginEngineName(headless bool) string {
|
||||
if headless {
|
||||
return "headless"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
// summarizeSession produces a concise human-readable summary of what an
|
||||
// auto-login captured, for the success log line.
|
||||
func summarizeSession(session *autologin.Session) string {
|
||||
if session == nil {
|
||||
return "no session"
|
||||
}
|
||||
var parts []string
|
||||
if n := len(session.Cookies); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d cookie(s)", n))
|
||||
}
|
||||
if session.Token != "" {
|
||||
parts = append(parts, "bearer token")
|
||||
}
|
||||
if n := len(session.LocalStorage); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d localStorage item(s)", n))
|
||||
}
|
||||
if n := len(session.SessionStorage); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d sessionStorage item(s)", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no usable session material"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// applyAutoLoginSession builds the concrete applied secret(s) from a captured
|
||||
// session. Cookies are applied as a cookie secret; an extracted token is applied
|
||||
// as a bearer header. When both are present (common for SPAs that set a session
|
||||
// cookie *and* a localStorage JWT) both are applied. The applied fields are
|
||||
// reset first so a re-authentication fully replaces the previous session.
|
||||
func (d *Dynamic) applyAutoLoginSession(session *autologin.Session) error {
|
||||
if d.Secret == nil {
|
||||
d.Secret = &Secret{}
|
||||
}
|
||||
// Reset previously applied auth so re-auth replaces (not appends to) it.
|
||||
d.Headers = nil
|
||||
d.Cookies = nil
|
||||
d.Token = ""
|
||||
d.Secrets = nil
|
||||
|
||||
// Stash captured web storage (headless logins only) and any extra derived
|
||||
// secrets on the shared fetchState so every domain-scoped value-copy and a
|
||||
// re-authentication observe the same, freshly-captured session. Reset both
|
||||
// each re-auth.
|
||||
if d.fetchState != nil {
|
||||
d.fetchState.webStorageLocal = session.LocalStorage
|
||||
d.fetchState.webStorageSession = session.SessionStorage
|
||||
d.fetchState.autoLoginSecrets = nil
|
||||
}
|
||||
|
||||
hasCookies := len(session.Cookies) > 0
|
||||
hasToken := session.Token != ""
|
||||
hasStorage := len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0
|
||||
if !hasCookies && !hasToken && !hasStorage {
|
||||
return errkit.New("auto-login produced no applicable session (no cookies, token or web storage)")
|
||||
}
|
||||
if !hasCookies && !hasToken {
|
||||
// Storage-only session (e.g. a pure localStorage-JWT SPA): there is no
|
||||
// HTTP-applicable secret, but the headless engine will replay the storage.
|
||||
// Leave the secret type empty so it yields no HTTP strategy.
|
||||
d.Type = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasCookies:
|
||||
d.Type = string(CookiesAuth)
|
||||
for _, c := range session.Cookies {
|
||||
d.Cookies = append(d.Cookies, Cookie{
|
||||
Key: c.Name,
|
||||
Value: c.Value,
|
||||
Domain: c.Domain,
|
||||
Path: c.Path,
|
||||
Secure: c.Secure,
|
||||
})
|
||||
// Expand secret domain scope from captured cookie domains so SSO
|
||||
// cookies are not silently dropped when LookupURL uses Domains, while
|
||||
// Apply still filters by each cookie's Domain/Path/Secure.
|
||||
if host := cookieDomainHost(c.Domain); host != "" && !domainListContains(d.Domains, host) {
|
||||
d.Domains = append(d.Domains, host)
|
||||
}
|
||||
}
|
||||
if hasToken && d.fetchState != nil {
|
||||
// Apply the token as an additional bearer header secret on the shared
|
||||
// fetchState so it reaches every domain the secret is scoped to.
|
||||
d.fetchState.autoLoginSecrets = append(d.fetchState.autoLoginSecrets, &Secret{
|
||||
Type: string(BearerTokenAuth),
|
||||
Domains: d.Domains,
|
||||
Token: session.Token,
|
||||
})
|
||||
}
|
||||
default: // token only
|
||||
d.Type = string(BearerTokenAuth)
|
||||
d.Token = session.Token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string argument.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookieDomainHost normalizes a Set-Cookie Domain attribute to a hostname
|
||||
// suitable for Secret.Domains lookup (strips a leading dot).
|
||||
func cookieDomainHost(domain string) string {
|
||||
domain = strings.TrimSpace(domain)
|
||||
domain = strings.TrimPrefix(domain, ".")
|
||||
return domain
|
||||
}
|
||||
|
||||
func domainListContains(domains []string, host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
for _, d := range domains {
|
||||
if strings.EqualFold(d, host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// kvSliceToMap converts a slice of KV pairs to a map for the autologin engine.
|
||||
func kvSliceToMap(kvs []KV) map[string]string {
|
||||
if len(kvs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
m[kv.Key] = kv.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAutoLoginConfig_Validate_Recording(t *testing.T) {
|
||||
recording := `{"steps": [
|
||||
{"type": "navigate", "url": "https://app.example.com/login"},
|
||||
{"type": "change", "value": "dave", "selectors": [["#user"]]},
|
||||
{"type": "change", "value": "secret", "selectors": [["#pass"]]},
|
||||
{"type": "click", "selectors": [["#submit"]]}
|
||||
]}`
|
||||
path := filepath.Join(t.TempDir(), "login.flow.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
|
||||
|
||||
// LoginURL intentionally omitted: it must be derived from the recording.
|
||||
cfg := &AutoLoginConfig{Recording: path, Username: "dave", Password: "secret"}
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.Headless, "a recording must force a headless login")
|
||||
require.Equal(t, "https://app.example.com/login", cfg.LoginURL, "login URL derived from first navigate step")
|
||||
require.Equal(t, []autologin.LoginStep{
|
||||
{Action: "navigate", Value: "https://app.example.com/login"},
|
||||
{Action: "fill", Selector: "#user", Value: "{{username}}"},
|
||||
{Action: "fill", Selector: "#pass", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
}, cfg.Steps, "recording must compile to placeholder-parameterized steps")
|
||||
}
|
||||
|
||||
func TestAutoLoginConfig_Validate_RecordingMissingFile(t *testing.T) {
|
||||
cfg := &AutoLoginConfig{Recording: filepath.Join(t.TempDir(), "nope.json"), Password: "x"}
|
||||
require.Error(t, cfg.Validate(), "a missing recording file must fail validation")
|
||||
}
|
||||
|
||||
func TestAutoLoginConfig_Validate_RecordingAndStepsExclusive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "login.flow.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"steps":[{"type":"navigate","url":"https://example.com/login"}]}`), 0o600))
|
||||
cfg := &AutoLoginConfig{
|
||||
Recording: path,
|
||||
Password: "x",
|
||||
Steps: []autologin.LoginStep{{Action: "click", Selector: "#x"}},
|
||||
}
|
||||
require.Error(t, cfg.Validate(), "recording and steps must be mutually exclusive")
|
||||
}
|
||||
|
||||
func newAutoLoginDynamic() *Dynamic {
|
||||
return &Dynamic{
|
||||
Secret: &Secret{Domains: []string{"app.example.com"}},
|
||||
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_CookiesOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(CookiesAuth), d.Type)
|
||||
require.Len(t, d.Cookies, 1)
|
||||
require.Equal(t, "session", d.Cookies[0].Key)
|
||||
require.Empty(t, d.Secrets, "no extra bearer secret when there is no token")
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_TokenOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{Token: "jwt-123"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(BearerTokenAuth), d.Type)
|
||||
require.Equal(t, "jwt-123", d.Token)
|
||||
require.Empty(t, d.Secrets)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_CookiesAndToken(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(CookiesAuth), d.Type)
|
||||
// The bearer lives on the shared fetchState (so it reaches every scoped
|
||||
// domain), not on the per-copy Secrets slice.
|
||||
require.Empty(t, d.Secrets, "bearer must not live on the per-copy Secrets slice")
|
||||
require.Len(t, d.fetchState.autoLoginSecrets, 1, "token should be applied as an additional bearer secret")
|
||||
require.Equal(t, string(BearerTokenAuth), d.fetchState.autoLoginSecrets[0].Type)
|
||||
require.Equal(t, "jwt-123", d.fetchState.autoLoginSecrets[0].Token)
|
||||
require.Equal(t, []string{"app.example.com"}, d.fetchState.autoLoginSecrets[0].Domains)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_Empty(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSummarizeSession(t *testing.T) {
|
||||
require.Equal(t, "no session", summarizeSession(nil))
|
||||
require.Equal(t, "no usable session material", summarizeSession(&autologin.Session{}))
|
||||
require.Equal(t, "2 cookie(s)", summarizeSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "a"}, {Name: "b"}},
|
||||
}))
|
||||
require.Equal(t, "1 cookie(s), bearer token, 2 localStorage item(s)", summarizeSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "a"}},
|
||||
Token: "jwt",
|
||||
LocalStorage: map[string]string{"x": "1", "y": "2"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAutoLoginEngineName(t *testing.T) {
|
||||
require.Equal(t, "headless", autoLoginEngineName(true))
|
||||
require.Equal(t, "http", autoLoginEngineName(false))
|
||||
}
|
||||
|
||||
// TestDynamic_WebStorage_ConcurrentReauth exercises the locking contract that
|
||||
// storage replay relies on: readers (the headless engine) call WebStorage while
|
||||
// re-authentication concurrently rewrites the captured storage on the shared
|
||||
// fetchState. Must be run under -race.
|
||||
func TestDynamic_WebStorage_ConcurrentReauth(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
require.NoError(t, d.Validate())
|
||||
|
||||
var gen atomic.Int64
|
||||
// Each (re)auth captures a fresh storage map, simulating a new session.
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
n := gen.Add(1)
|
||||
// Cookie + token + storage so every shared-state write path (Secret,
|
||||
// fetchState.autoLoginSecrets, webStorage) is exercised under -race.
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: fmt.Sprintf("v%d", n)}},
|
||||
Token: fmt.Sprintf("token-%d", n),
|
||||
LocalStorage: map[string]string{"jwt": fmt.Sprintf("token-%d", n)},
|
||||
})
|
||||
}
|
||||
|
||||
// Share the session across value-copies, as the real provider does.
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const workers = 8
|
||||
const iterations = 200
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
switch id % 4 {
|
||||
case 0:
|
||||
// Force a re-auth (rewrites the storage map under the write lock).
|
||||
strategy.Dynamic.MarkStale()
|
||||
_ = strategy.Dynamic.Fetch(false)
|
||||
case 1:
|
||||
// Read + iterate storage the way resolveBrowserStorage does.
|
||||
local, session := strategy.WebStorage()
|
||||
for k, v := range local {
|
||||
_ = k + v
|
||||
}
|
||||
for k, v := range session {
|
||||
_ = k + v
|
||||
}
|
||||
// Also materialize strategies (reads shared autoLoginSecrets).
|
||||
_ = strategy.Dynamic.GetStrategies()
|
||||
case 2:
|
||||
strategy.Dynamic.ApplyStrategies(func(AuthStrategy) {})
|
||||
default:
|
||||
_ = strategy.Dynamic.NotifyResponse(401, 0)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// After the storm, a fresh fetch must still yield a consistent session.
|
||||
local, _ := strategy.WebStorage()
|
||||
require.NotEmpty(t, local["jwt"], "storage must remain populated after concurrent re-auth")
|
||||
}
|
||||
|
||||
// TestDynamicClose_WipesCapturedSession verifies that Close drops the in-memory
|
||||
// session captured by an auto-login (cookies, bearer token, web storage and
|
||||
// extracted values) without contacting the target.
|
||||
func TestDynamicClose_WipesCapturedSession(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
LocalStorage: map[string]string{"jwt": "jwt-123"},
|
||||
})
|
||||
}
|
||||
require.NoError(t, d.Fetch(false))
|
||||
d.Extracted = map[string]interface{}{"token": "jwt-123"}
|
||||
|
||||
// Sanity: the session is populated before Close.
|
||||
require.NotEmpty(t, d.Cookies)
|
||||
local, _ := d.WebStorage()
|
||||
require.NotEmpty(t, local["jwt"])
|
||||
|
||||
d.Close()
|
||||
|
||||
require.Empty(t, d.Cookies, "cookies must be wiped")
|
||||
require.Empty(t, d.Token, "token must be wiped")
|
||||
require.Empty(t, d.Headers, "headers must be wiped")
|
||||
require.Nil(t, d.Extracted, "extracted values must be cleared")
|
||||
require.False(t, d.IsExpired(), "a closed session reports not-expired (unfetched)")
|
||||
require.Equal(t, []string{"app.example.com"}, d.Domains, "scoping domains must be preserved")
|
||||
|
||||
localAfter, sessionAfter := d.fetchState.webStorageLocal, d.fetchState.webStorageSession
|
||||
require.Nil(t, localAfter, "captured local storage must be cleared")
|
||||
require.Nil(t, sessionAfter, "captured session storage must be cleared")
|
||||
}
|
||||
|
||||
// TestAutoLogin_MultiDomain_BearerSharedAcrossCopies reproduces a sharing bug:
|
||||
// the auto-login session (cookie + bearer token) must be applied on *every*
|
||||
// domain the secret is scoped to, not just the one whose DynamicAuthStrategy
|
||||
// copy happened to trigger the login. init() creates one value-copy per domain,
|
||||
// all sharing *Secret and *fetchState; the extra bearer secret must be visible
|
||||
// to sibling copies too.
|
||||
func TestAutoLogin_MultiDomain_BearerSharedAcrossCopies(t *testing.T) {
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{Domains: []string{"app.example.com", "api.example.com"}},
|
||||
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror init(): one copy per domain, sharing the pointers.
|
||||
copyApp := &DynamicAuthStrategy{Dynamic: *d}
|
||||
copyAPI := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
// The app-domain copy triggers the login.
|
||||
appStrategies := copyApp.Dynamic.GetStrategies()
|
||||
require.Len(t, appStrategies, 2, "app domain must get cookie + bearer")
|
||||
|
||||
// The api-domain copy (login already done, shared fetchState) must ALSO see
|
||||
// both the cookie and the bearer token.
|
||||
apiStrategies := copyAPI.Dynamic.GetStrategies()
|
||||
require.Len(t, apiStrategies, 2, "api domain must also get cookie + bearer (shared session)")
|
||||
|
||||
// Concretely, the bearer must be applied to a request on the api domain.
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/data", nil)
|
||||
copyAPI.Apply(req)
|
||||
require.Equal(t, "Bearer jwt-123", req.Header.Get("Authorization"), "bearer token must reach the api domain")
|
||||
require.NotEmpty(t, req.Header.Get("Cookie"), "session cookie must reach the api domain")
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_WebStorageCaptured(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetAutoLoginCallback(nil)
|
||||
// Stub the fetch to bypass the network and simulate a headless capture.
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
LocalStorage: map[string]string{"jwt": "eyJ..."},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
})
|
||||
}
|
||||
|
||||
local, session := d.WebStorage()
|
||||
require.Equal(t, map[string]string{"jwt": "eyJ..."}, local)
|
||||
require.Equal(t, map[string]string{"csrf": "tok"}, session)
|
||||
|
||||
// And the DynamicAuthStrategy must surface the same storage to the engine.
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
l2, s2 := strategy.WebStorage()
|
||||
require.Equal(t, "eyJ...", l2["jwt"])
|
||||
require.Equal(t, "tok", s2["csrf"])
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_StorageOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
// Storage-only session (pure localStorage-JWT SPA): no HTTP secret, but the
|
||||
// session is still valid because storage will be replayed by the engine.
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
LocalStorage: map[string]string{"jwt": "x"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, d.Type, "storage-only session yields no HTTP strategy")
|
||||
require.Equal(t, map[string]string{"jwt": "x"}, d.fetchState.webStorageLocal)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_ReauthReplaces(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
// First auth: cookies + token (creates an extra bearer secret).
|
||||
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "v1"}},
|
||||
Token: "jwt-1",
|
||||
}))
|
||||
require.Len(t, d.fetchState.autoLoginSecrets, 1)
|
||||
|
||||
// Re-auth: cookies only -> the previous bearer secret and cookies must be
|
||||
// fully replaced, not appended.
|
||||
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "v2"}},
|
||||
}))
|
||||
require.Empty(t, d.fetchState.autoLoginSecrets, "stale bearer secret must be cleared on re-auth")
|
||||
require.Len(t, d.Cookies, 1)
|
||||
require.Equal(t, "v2", d.Cookies[0].Value, "cookie value must be refreshed")
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
@@ -25,9 +27,15 @@ func NewCookiesAuthStrategy(data *Secret) *CookiesAuthStrategy {
|
||||
func (s *CookiesAuthStrategy) Apply(req *http.Request) {
|
||||
var names []string
|
||||
for _, cookie := range s.Data.Cookies {
|
||||
if !cookieAppliesToRequest(cookie, req.URL) {
|
||||
continue
|
||||
}
|
||||
c := &http.Cookie{
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
}
|
||||
req.AddCookie(c)
|
||||
names = append(names, cookie.Key)
|
||||
@@ -38,8 +46,15 @@ func (s *CookiesAuthStrategy) Apply(req *http.Request) {
|
||||
// ApplyOnRR applies the cookies auth strategy to the retryable request
|
||||
func (s *CookiesAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
existingCookies := req.Cookies()
|
||||
var reqURL *url.URL
|
||||
if req.URL != nil {
|
||||
reqURL = req.URL.URL
|
||||
}
|
||||
|
||||
for _, newCookie := range s.Data.Cookies {
|
||||
if !cookieAppliesToRequest(newCookie, reqURL) {
|
||||
continue
|
||||
}
|
||||
for i, existing := range existingCookies {
|
||||
if existing.Name == newCookie.Key {
|
||||
existingCookies = slices.Delete(existingCookies, i, i+1)
|
||||
@@ -53,14 +68,70 @@ func (s *CookiesAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
for _, cookie := range existingCookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
// Add new cookies
|
||||
// Add new cookies that match this request's scope
|
||||
var names []string
|
||||
for _, cookie := range s.Data.Cookies {
|
||||
if !cookieAppliesToRequest(cookie, reqURL) {
|
||||
continue
|
||||
}
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
})
|
||||
names = append(names, cookie.Key)
|
||||
}
|
||||
markCookieAuth(req.Request, names)
|
||||
}
|
||||
|
||||
// cookieAppliesToRequest reports whether a captured/configured cookie should be
|
||||
// attached to reqURL. Unscoped cookies (empty Domain) always apply, preserving
|
||||
// backward compatibility for hand-written secrets.
|
||||
func cookieAppliesToRequest(cookie Cookie, reqURL *url.URL) bool {
|
||||
if reqURL == nil {
|
||||
return cookie.Domain == ""
|
||||
}
|
||||
if cookie.Secure && !strings.EqualFold(reqURL.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
if cookie.Domain != "" && !cookieDomainMatchesHost(cookie.Domain, reqURL.Hostname()) {
|
||||
return false
|
||||
}
|
||||
path := cookie.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
reqPath := reqURL.EscapedPath()
|
||||
if reqPath == "" {
|
||||
reqPath = "/"
|
||||
}
|
||||
return cookiePathMatches(path, reqPath)
|
||||
}
|
||||
|
||||
// cookieDomainMatchesHost implements a conservative Domain attribute match:
|
||||
// exact host equality, or a leading-dot domain matching a subdomain suffix.
|
||||
func cookieDomainMatchesHost(domain, host string) bool {
|
||||
domain = strings.TrimSpace(strings.ToLower(domain))
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if domain == "" || host == "" {
|
||||
return false
|
||||
}
|
||||
domain = strings.TrimPrefix(domain, ".")
|
||||
if host == domain {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(host, "."+domain)
|
||||
}
|
||||
|
||||
// cookiePathMatches implements the RFC 6265 path-match algorithm.
|
||||
func cookiePathMatches(cookiePath, reqPath string) bool {
|
||||
if !strings.HasPrefix(reqPath, cookiePath) {
|
||||
return false
|
||||
}
|
||||
if len(reqPath) == len(cookiePath) || strings.HasSuffix(cookiePath, "/") {
|
||||
return true
|
||||
}
|
||||
return reqPath[len(cookiePath)] == '/'
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCookieAppliesToRequest_Scope(t *testing.T) {
|
||||
t.Run("unscoped cookies always apply", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://app.example.com/dashboard", nil)
|
||||
require.True(t, cookieAppliesToRequest(Cookie{Key: "s", Value: "1"}, req.URL))
|
||||
})
|
||||
|
||||
t.Run("domain and path filter", func(t *testing.T) {
|
||||
c := Cookie{Key: "s", Value: "1", Domain: ".example.com", Path: "/app"}
|
||||
ok, _ := http.NewRequest(http.MethodGet, "https://api.example.com/app/x", nil)
|
||||
badHost, _ := http.NewRequest(http.MethodGet, "https://evil.com/app", nil)
|
||||
badPath, _ := http.NewRequest(http.MethodGet, "https://api.example.com/other", nil)
|
||||
require.True(t, cookieAppliesToRequest(c, ok.URL))
|
||||
require.False(t, cookieAppliesToRequest(c, badHost.URL))
|
||||
require.False(t, cookieAppliesToRequest(c, badPath.URL))
|
||||
})
|
||||
|
||||
t.Run("secure cookies skip http", func(t *testing.T) {
|
||||
c := Cookie{Key: "s", Value: "1", Secure: true}
|
||||
httpsReq, _ := http.NewRequest(http.MethodGet, "https://app.example.com/", nil)
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, "http://app.example.com/", nil)
|
||||
require.True(t, cookieAppliesToRequest(c, httpsReq.URL))
|
||||
require.False(t, cookieAppliesToRequest(c, httpReq.URL))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCookiesAuthStrategy_PreservesScopedCookies(t *testing.T) {
|
||||
s := NewCookiesAuthStrategy(&Secret{
|
||||
Cookies: []Cookie{
|
||||
{Key: "session", Value: "app", Domain: "app.example.com", Path: "/"},
|
||||
{Key: "session", Value: "idp", Domain: "login.example.com", Path: "/"},
|
||||
{Key: "csrf", Value: "x", Domain: "app.example.com", Path: "/login"},
|
||||
},
|
||||
})
|
||||
|
||||
appReq, _ := http.NewRequest(http.MethodGet, "https://app.example.com/dashboard", nil)
|
||||
s.Apply(appReq)
|
||||
got := map[string]string{}
|
||||
for _, c := range appReq.Cookies() {
|
||||
got[c.Name] = c.Value
|
||||
}
|
||||
require.Equal(t, "app", got["session"], "app-scoped session must apply")
|
||||
require.NotContains(t, got, "csrf", "login-path csrf must not apply to /dashboard")
|
||||
|
||||
idpReq, _ := http.NewRequest(http.MethodGet, "https://login.example.com/", nil)
|
||||
s.Apply(idpReq)
|
||||
got = map[string]string{}
|
||||
for _, c := range idpReq.Cookies() {
|
||||
got[c.Name] = c.Value
|
||||
}
|
||||
require.Equal(t, "idp", got["session"], "idp-scoped session must apply on idp host")
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package authx
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
||||
@@ -18,16 +20,59 @@ import (
|
||||
|
||||
type LazyFetchSecret func(d *Dynamic) error
|
||||
|
||||
// fetchState holds the sync.Once and error for thread-safe fetching.
|
||||
// This is stored as a pointer in Dynamic so that value copies share the same state.
|
||||
// fetchState holds the session state for a dynamic secret and makes it safe
|
||||
// for concurrent use. It is stored as a pointer in Dynamic so that value copies
|
||||
// (e.g. the one held inside DynamicAuthStrategy) share the same session.
|
||||
//
|
||||
// Unlike a plain sync.Once, this state machine supports re-authentication: the
|
||||
// login flow can be re-run when the session goes stale (explicitly marked,
|
||||
// detected from a response status, or after a configured refresh interval).
|
||||
//
|
||||
// Locking contract:
|
||||
// - Fetch/Refresh take the write lock while (re)running the login callback.
|
||||
// - The dynamic apply path (ApplyStrategies) holds the read lock while reading
|
||||
// the rendered secret, so request-time reads never race a concurrent re-auth.
|
||||
type fetchState struct {
|
||||
once sync.Once
|
||||
err error
|
||||
mu sync.RWMutex
|
||||
fetched bool // whether the login callback has completed at least once
|
||||
attempted bool // whether the login callback has run, even if it failed
|
||||
stale bool // whether the session must be re-authenticated on next fetch
|
||||
fetchedAt time.Time // time of the last successful/attempted fetch
|
||||
err error // error from the most recent fetch attempt
|
||||
// generation increments on every successful login so a late 401 from an
|
||||
// older session can be ignored after a concurrent re-authentication.
|
||||
generation uint64
|
||||
// webStorageLocal/webStorageSession hold browser web storage captured by a
|
||||
// headless auto-login, to be replayed into headless scan pages. Stored here
|
||||
// (on the shared fetchState pointer) so all Dynamic value-copies and a
|
||||
// re-authentication observe the same, freshly-captured storage.
|
||||
webStorageLocal map[string]string
|
||||
webStorageSession map[string]string
|
||||
// autoLoginSecrets holds extra applied secrets derived from an auto-login
|
||||
// session (e.g. a bearer token captured alongside a session cookie). They
|
||||
// live on the shared fetchState rather than the per-copy Dynamic.Secrets
|
||||
// slice so every domain-scoped value-copy applies the same captured session.
|
||||
autoLoginSecrets []*Secret
|
||||
// owner is the goroutine running the fetch callback. Nested Fetch/GetStrategies
|
||||
// on that goroutine must not wait on once (sync.Once deadlocks on re-entry).
|
||||
// on that goroutine must not wait on the write lock (it would deadlock).
|
||||
owner atomic.Int64
|
||||
// answered is set once a response that is not a reauth status code is seen
|
||||
// for the current session. It is reset on every successful login.
|
||||
answered atomic.Bool
|
||||
// statusReauths counts consecutive status-triggered re-authentications whose
|
||||
// session never got a response other than a reauth status code. Past
|
||||
// maxStatusReauths the session is kept, so an app that rejects every
|
||||
// request does not get one login per request (account lockout, WAF).
|
||||
statusReauths int
|
||||
// reauthCapLogged is set once the cap warning was printed for the current
|
||||
// streak, so it is printed once and not on every response.
|
||||
reauthCapLogged bool
|
||||
}
|
||||
|
||||
// maxStatusReauths caps consecutive re-authentications triggered by reauth
|
||||
// status codes when the fresh session still gets only reauth status codes.
|
||||
const maxStatusReauths = 2
|
||||
|
||||
var (
|
||||
_ json.Unmarshaler = &Dynamic{}
|
||||
)
|
||||
@@ -37,16 +82,44 @@ var (
|
||||
// ex: username and password are dynamic secrets, the actual secret is the token obtained
|
||||
// after authenticating with the username and password
|
||||
type Dynamic struct {
|
||||
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
|
||||
Secrets []*Secret `yaml:"secrets"`
|
||||
TemplatePath string `json:"template" yaml:"template"`
|
||||
Variables []KV `json:"variables" yaml:"variables"`
|
||||
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
|
||||
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
|
||||
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
|
||||
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
|
||||
Secrets []*Secret `yaml:"secrets"`
|
||||
TemplatePath string `json:"template" yaml:"template"`
|
||||
Variables []KV `json:"variables" yaml:"variables"`
|
||||
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
|
||||
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
|
||||
|
||||
// AutoLogin, when set, replaces the template-based login (TemplatePath) with
|
||||
// a turnkey form-based auto-login: nuclei fetches the login page, detects the
|
||||
// form and submits the supplied credentials, capturing the session
|
||||
// automatically. It is mutually exclusive with TemplatePath.
|
||||
AutoLogin *AutoLoginConfig `json:"auto-login" yaml:"auto-login"`
|
||||
|
||||
// RefreshInterval, when set (e.g. "15m"), causes the session to be
|
||||
// re-authenticated automatically once the rendered secret is older than the
|
||||
// interval. When empty/zero the session is fetched once and never expires by time.
|
||||
RefreshInterval string `json:"refresh-interval" yaml:"refresh-interval"`
|
||||
// ReauthStatusCodes is the set of HTTP response status codes that indicate
|
||||
// the session has expired (e.g. [401, 403]). When a matching response is
|
||||
// observed, the session is marked stale and re-authenticated before the next
|
||||
// request. When empty, response-triggered re-authentication is disabled.
|
||||
ReauthStatusCodes []int `json:"reauth-status-codes" yaml:"reauth-status-codes"`
|
||||
|
||||
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
|
||||
// fetchState is shared across value-copies of Dynamic (e.g., inside DynamicAuthStrategy).
|
||||
// It must be initialized via Validate() before calling Fetch().
|
||||
fetchState *fetchState `json:"-" yaml:"-"`
|
||||
// refreshInterval is the parsed form of RefreshInterval.
|
||||
refreshInterval time.Duration `json:"-" yaml:"-"`
|
||||
// autoLoginClient is an optional template HTTP client (proxy/TLS) reused for
|
||||
// auto-login requests. nil means use the autologin engine's defaults.
|
||||
autoLoginClient *http.Client `json:"-" yaml:"-"`
|
||||
// origSecret/origSecrets hold pristine copies of the secret templates (with
|
||||
// their {{var}} placeholders intact) captured before the first fetch. They are
|
||||
// used to re-render the secret on each re-authentication, since substitution
|
||||
// is destructive (placeholders are replaced with concrete values in place).
|
||||
origSecret *Secret `json:"-" yaml:"-"`
|
||||
origSecrets []*Secret `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
func (d *Dynamic) GetDomainAndDomainRegex() ([]string, []string) {
|
||||
@@ -87,12 +160,43 @@ func (d *Dynamic) Validate() error {
|
||||
// NOTE: Validate() must not be called concurrently with Fetch()/GetStrategies().
|
||||
// Re-validating resets fetch state and allows re-fetching.
|
||||
d.fetchState = &fetchState{}
|
||||
d.refreshInterval = 0
|
||||
|
||||
// Auto-login is a template-free alternative to TemplatePath. When set, it is
|
||||
// validated on its own and the template/variables requirements do not apply;
|
||||
// domains (used for scoping the captured session) are still required.
|
||||
if d.AutoLogin != nil {
|
||||
if d.TemplatePath != "" {
|
||||
return errkit.New("auto-login and template are mutually exclusive for dynamic secret")
|
||||
}
|
||||
if err := d.AutoLogin.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
domains, domainsRegex := d.GetDomainAndDomainRegex()
|
||||
if len(domains) == 0 && len(domainsRegex) == 0 {
|
||||
return errkit.New("domains or domains-regex are required for auto-login dynamic secret")
|
||||
}
|
||||
if err := d.parseRefreshInterval(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The applied secret is built from the captured session at fetch time, so
|
||||
// skip the (pre-fetch) Secret.Validate which would reject the still-empty
|
||||
// cookies/token. We only need its domains, captured above.
|
||||
if d.Secret != nil {
|
||||
d.skipCookieParse = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.TemplatePath == "" {
|
||||
return errkit.New(" template-path is required for dynamic secret")
|
||||
}
|
||||
if len(d.Variables) == 0 {
|
||||
return errkit.New("variables are required for dynamic secret")
|
||||
}
|
||||
if err := d.parseRefreshInterval(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.Secret != nil {
|
||||
d.skipCookieParse = true // skip cookie parsing in dynamic secrets during validation
|
||||
@@ -109,8 +213,31 @@ func (d *Dynamic) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret
|
||||
// parseRefreshInterval parses RefreshInterval into refreshInterval.
|
||||
func (d *Dynamic) parseRefreshInterval() error {
|
||||
if d.RefreshInterval == "" {
|
||||
return nil
|
||||
}
|
||||
dur, err := time.ParseDuration(d.RefreshInterval)
|
||||
if err != nil {
|
||||
return errkit.Newf("invalid refresh-interval %q for dynamic secret: %s", d.RefreshInterval, err)
|
||||
}
|
||||
if dur < 0 {
|
||||
return errkit.New("refresh-interval cannot be negative for dynamic secret")
|
||||
}
|
||||
d.refreshInterval = dur
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret.
|
||||
//
|
||||
// The provided callback is responsible for running the login flow and populating
|
||||
// d.Extracted. The wrapper here renders the secret templates with the freshly
|
||||
// extracted values. It snapshots the pristine secret templates on first use so
|
||||
// that re-authentication can re-render them with new values (template
|
||||
// substitution is destructive and would otherwise consume the placeholders).
|
||||
func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
d.snapshotTemplates()
|
||||
d.fetchCallback = func(d *Dynamic) error {
|
||||
err := callback(d)
|
||||
if err != nil {
|
||||
@@ -121,12 +248,16 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
}
|
||||
|
||||
if d.Secret != nil {
|
||||
restoreSecretTemplate(d.Secret, d.origSecret)
|
||||
if err := d.applyValuesToSecret(d.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range d.Secrets {
|
||||
for i, secret := range d.Secrets {
|
||||
if i < len(d.origSecrets) {
|
||||
restoreSecretTemplate(secret, d.origSecrets[i])
|
||||
}
|
||||
if err := d.applyValuesToSecret(secret); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,6 +266,47 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTemplates captures pristine copies of the secret templates (with their
|
||||
// {{var}} placeholders intact) so they can be re-rendered on re-authentication.
|
||||
func (d *Dynamic) snapshotTemplates() {
|
||||
if d.Secret != nil {
|
||||
d.origSecret = cloneSecretTemplate(d.Secret)
|
||||
}
|
||||
if len(d.Secrets) > 0 {
|
||||
d.origSecrets = make([]*Secret, len(d.Secrets))
|
||||
for i, secret := range d.Secrets {
|
||||
d.origSecrets[i] = cloneSecretTemplate(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneSecretTemplate deep-copies the templated fields of a secret so the
|
||||
// original placeholders survive destructive in-place substitution.
|
||||
func cloneSecretTemplate(s *Secret) *Secret {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *s
|
||||
clone.Headers = append([]KV(nil), s.Headers...)
|
||||
clone.Cookies = append([]Cookie(nil), s.Cookies...)
|
||||
clone.Params = append([]KV(nil), s.Params...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// restoreSecretTemplate resets the templated fields of live back to the pristine
|
||||
// template captured in snap, so the next substitution starts from placeholders.
|
||||
func restoreSecretTemplate(live, snap *Secret) {
|
||||
if live == nil || snap == nil {
|
||||
return
|
||||
}
|
||||
live.Headers = append([]KV(nil), snap.Headers...)
|
||||
live.Cookies = append([]Cookie(nil), snap.Cookies...)
|
||||
live.Params = append([]KV(nil), snap.Params...)
|
||||
live.Username = snap.Username
|
||||
live.Password = snap.Password
|
||||
live.Token = snap.Token
|
||||
}
|
||||
|
||||
func (d *Dynamic) applyValuesToSecret(secret *Secret) error {
|
||||
// evaluate headers
|
||||
for i, header := range secret.Headers {
|
||||
@@ -208,20 +380,89 @@ func (d *Dynamic) GetStrategies() []AuthStrategy {
|
||||
// does not terminate the entire scan process.
|
||||
_ = d.Fetch(false)
|
||||
|
||||
if d.fetchState != nil && d.fetchState.err != nil {
|
||||
if d.fetchState == nil {
|
||||
return nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
return nil
|
||||
}
|
||||
var strategies []AuthStrategy
|
||||
if d.Secret != nil {
|
||||
strategies = append(strategies, d.GetStrategy())
|
||||
if s := d.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.Secrets {
|
||||
strategies = append(strategies, secret.GetStrategy())
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.fetchState.autoLoginSecrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
}
|
||||
return strategies
|
||||
}
|
||||
|
||||
// Fetch fetches the dynamic secret
|
||||
// ApplyStrategies fetches (or re-authenticates) the session if needed and then
|
||||
// applies each resolved auth strategy via the provided apply func while holding
|
||||
// the read lock. This guarantees the rendered secret is never read at request
|
||||
// time while a concurrent re-authentication is rewriting it.
|
||||
// The returned generation identifies the session that was applied (0 if none).
|
||||
func (d *Dynamic) ApplyStrategies(apply func(AuthStrategy)) uint64 {
|
||||
// A login callback can issue requests that pass through auth lookup again.
|
||||
// Do not apply this still-unresolved dynamic secret or try to take its read
|
||||
// lock while the same goroutine owns the fetch write lock.
|
||||
if d.fetchingOnThisGoroutine() {
|
||||
return 0
|
||||
}
|
||||
// Fetch (re)authenticates under the write lock if the session is missing or stale.
|
||||
_ = d.Fetch(false)
|
||||
if d.fetchState == nil {
|
||||
return 0
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
return 0
|
||||
}
|
||||
if d.Secret != nil {
|
||||
if s := d.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.Secrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.fetchState.autoLoginSecrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
return d.fetchState.generation
|
||||
}
|
||||
|
||||
// SessionGeneration returns the current successful-login generation, or 0.
|
||||
func (d *Dynamic) SessionGeneration() uint64 {
|
||||
if d.fetchState == nil {
|
||||
return 0
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if !d.fetchState.fetched {
|
||||
return 0
|
||||
}
|
||||
return d.fetchState.generation
|
||||
}
|
||||
|
||||
// Fetch fetches the dynamic secret, (re)running the login flow when the session
|
||||
// has not been fetched yet or has gone stale (explicitly marked, expired by
|
||||
// refresh-interval, or invalidated by an observed response status code).
|
||||
// if isFatal is true, it will stop the execution if the secret could not be fetched
|
||||
func (d *Dynamic) Fetch(isFatal bool) error {
|
||||
if d.fetchState == nil {
|
||||
@@ -235,20 +476,205 @@ func (d *Dynamic) Fetch(isFatal bool) error {
|
||||
return d.fetchState.err
|
||||
}
|
||||
|
||||
d.fetchState.once.Do(func() {
|
||||
d.fetchState.owner.Store(goroutineID())
|
||||
defer d.fetchState.owner.Store(0)
|
||||
if d.fetchCallback == nil {
|
||||
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
|
||||
return
|
||||
}
|
||||
d.fetchState.err = d.fetchCallback(d)
|
||||
})
|
||||
|
||||
if d.fetchState.err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.fetchState.err)
|
||||
d.fetchState.mu.Lock()
|
||||
if d.needsFetchLocked() {
|
||||
d.runFetchLocked()
|
||||
}
|
||||
return d.fetchState.err
|
||||
err := d.fetchState.err
|
||||
d.fetchState.mu.Unlock()
|
||||
|
||||
if err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// needsFetchLocked reports whether the login callback must run. A failed login
|
||||
// is not retried on every request, as that would send one login per scan
|
||||
// request (account lockout, WAF bans). It is retried only once the refresh
|
||||
// interval has passed since the failed attempt. The caller must hold the lock.
|
||||
func (d *Dynamic) needsFetchLocked() bool {
|
||||
if !d.fetchState.fetched {
|
||||
if !d.fetchState.attempted {
|
||||
return true
|
||||
}
|
||||
return d.refreshInterval > 0 && time.Since(d.fetchState.fetchedAt) > d.refreshInterval
|
||||
}
|
||||
return d.isExpiredLocked()
|
||||
}
|
||||
|
||||
// Refresh forces an immediate re-authentication, re-running the login flow
|
||||
// regardless of the current session state.
|
||||
func (d *Dynamic) Refresh(isFatal bool) error {
|
||||
if d.fetchState == nil {
|
||||
if isFatal {
|
||||
gologger.Fatal().Msgf("Could not refresh dynamic secret: Validate() must be called before Refresh()")
|
||||
}
|
||||
return errkit.New("dynamic secret not validated: call Validate() before Refresh()")
|
||||
}
|
||||
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.fetched = false
|
||||
d.fetchState.stale = false
|
||||
d.runFetchLocked()
|
||||
err := d.fetchState.err
|
||||
d.fetchState.mu.Unlock()
|
||||
|
||||
if err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not refresh dynamic secret: %s\n", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkStale marks the session for re-authentication on the next fetch. It is
|
||||
// cheap and does not perform any network I/O; the actual re-auth happens on the
|
||||
// next Fetch/ApplyStrategies call so it never runs in a response-handling path.
|
||||
func (d *Dynamic) MarkStale() {
|
||||
if d.fetchState == nil {
|
||||
return
|
||||
}
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.stale = true
|
||||
d.fetchState.mu.Unlock()
|
||||
}
|
||||
|
||||
// NotifyResponse inspects a response and, if its status code is configured as a
|
||||
// session-expiry signal, marks the session stale so it is re-authenticated
|
||||
// before the next request. It returns true if re-authentication was triggered.
|
||||
//
|
||||
// generation identifies the session that authenticated the request. When
|
||||
// non-zero and different from the current session generation, the signal is
|
||||
// ignored (a late 401 from an older session must not invalidate a fresher one).
|
||||
// Pass 0 when the generation is unknown (legacy callers).
|
||||
//
|
||||
// Re-authentication is capped: when maxStatusReauths consecutive fresh
|
||||
// sessions got only reauth status codes, the login evidently does not change
|
||||
// the response, so the session is kept until a request gets another status.
|
||||
func (d *Dynamic) NotifyResponse(statusCode int, generation uint64) bool {
|
||||
if d.fetchState == nil || len(d.ReauthStatusCodes) == 0 {
|
||||
return false
|
||||
}
|
||||
if !d.shouldReauthOnStatus(statusCode) {
|
||||
d.recordAnswer(generation)
|
||||
return false
|
||||
}
|
||||
d.fetchState.mu.Lock()
|
||||
defer d.fetchState.mu.Unlock()
|
||||
if !d.fetchState.fetched {
|
||||
return false
|
||||
}
|
||||
if generation != 0 && generation != d.fetchState.generation {
|
||||
return false
|
||||
}
|
||||
if d.fetchState.stale {
|
||||
return true
|
||||
}
|
||||
if d.fetchState.answered.Load() {
|
||||
d.fetchState.statusReauths = 0
|
||||
d.fetchState.reauthCapLogged = false
|
||||
}
|
||||
if d.fetchState.statusReauths >= maxStatusReauths {
|
||||
if !d.fetchState.reauthCapLogged {
|
||||
d.fetchState.reauthCapLogged = true
|
||||
gologger.Warning().Msgf("dynamic secret: %d re-authentications in a row did not change the %d response; not re-authenticating again until a request gets another status", maxStatusReauths, statusCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
d.fetchState.statusReauths++
|
||||
d.fetchState.stale = true
|
||||
return true
|
||||
}
|
||||
|
||||
// recordAnswer notes that the session for generation got a response that is
|
||||
// not a reauth status code, which re-arms status-triggered re-authentication.
|
||||
func (d *Dynamic) recordAnswer(generation uint64) {
|
||||
if d.fetchState.answered.Load() {
|
||||
return
|
||||
}
|
||||
// stored under the read lock so a concurrent login cannot reset it in between
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.fetched && (generation == 0 || generation == d.fetchState.generation) {
|
||||
d.fetchState.answered.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// WebStorage returns the browser web storage (localStorage/sessionStorage)
|
||||
// captured by a headless auto-login, fetching the session first if needed. It
|
||||
// returns nil maps when no storage was captured (e.g. HTTP auto-login or a
|
||||
// fetch error).
|
||||
func (d *Dynamic) WebStorage() (map[string]string, map[string]string) {
|
||||
_ = d.Fetch(false)
|
||||
if d.fetchState == nil {
|
||||
return nil, nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.fetchState.webStorageLocal, d.fetchState.webStorageSession
|
||||
}
|
||||
|
||||
// IsExpired reports whether the session is currently considered expired/stale.
|
||||
func (d *Dynamic) IsExpired() bool {
|
||||
if d.fetchState == nil {
|
||||
return false
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
return d.isExpiredLocked()
|
||||
}
|
||||
|
||||
// runFetchLocked runs the login callback. The caller must hold the write lock.
|
||||
func (d *Dynamic) runFetchLocked() {
|
||||
d.fetchState.owner.Store(goroutineID())
|
||||
defer d.fetchState.owner.Store(0)
|
||||
if d.fetchCallback == nil {
|
||||
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
|
||||
return
|
||||
}
|
||||
d.fetchState.attempted = true
|
||||
d.fetchState.fetchedAt = time.Now()
|
||||
d.fetchState.err = d.fetchCallback(d)
|
||||
if d.fetchState.err != nil {
|
||||
// A failed (re-)login drops the session: requests go out without it
|
||||
// and the login is retried only as needsFetchLocked allows.
|
||||
d.fetchState.fetched = false
|
||||
d.fetchState.stale = false
|
||||
return
|
||||
}
|
||||
d.fetchState.fetched = true
|
||||
d.fetchState.stale = false
|
||||
d.fetchState.fetchedAt = time.Now()
|
||||
d.fetchState.generation++
|
||||
d.fetchState.answered.Store(false)
|
||||
}
|
||||
|
||||
// isExpiredLocked reports whether the session needs re-authentication. The
|
||||
// caller must hold at least the read lock.
|
||||
func (d *Dynamic) isExpiredLocked() bool {
|
||||
if !d.fetchState.fetched {
|
||||
return false
|
||||
}
|
||||
if d.fetchState.stale {
|
||||
return true
|
||||
}
|
||||
if d.refreshInterval > 0 && time.Since(d.fetchState.fetchedAt) > d.refreshInterval {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldReauthOnStatus reports whether the given status code is configured as a
|
||||
// session-expiry signal.
|
||||
func (d *Dynamic) shouldReauthOnStatus(statusCode int) bool {
|
||||
for _, code := range d.ReauthStatusCodes {
|
||||
if code == statusCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Error returns the error if any
|
||||
@@ -256,9 +682,55 @@ func (d *Dynamic) Error() error {
|
||||
if d.fetchState == nil {
|
||||
return nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
return d.fetchState.err
|
||||
}
|
||||
|
||||
// Close drops the in-memory session captured for this dynamic secret: the
|
||||
// applied cookies/token/headers, any captured web storage and derived secrets,
|
||||
// and the extracted values. It does not contact the server (sessions are left
|
||||
// to expire on their own); it simply ensures nuclei does not retain live
|
||||
// session material after the scan, which matters when nuclei is embedded as a
|
||||
// library and the process outlives the scan. The session is also marked
|
||||
// unfetched so a later reuse re-authenticates from scratch.
|
||||
func (d *Dynamic) Close() {
|
||||
if d.fetchState != nil {
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.webStorageLocal = nil
|
||||
d.fetchState.webStorageSession = nil
|
||||
for _, s := range d.fetchState.autoLoginSecrets {
|
||||
wipeSecretSession(s)
|
||||
}
|
||||
d.fetchState.autoLoginSecrets = nil
|
||||
d.fetchState.fetched = false
|
||||
d.fetchState.attempted = false
|
||||
d.fetchState.stale = false
|
||||
d.fetchState.err = nil
|
||||
d.fetchState.mu.Unlock()
|
||||
}
|
||||
wipeSecretSession(d.Secret)
|
||||
for _, s := range d.Secrets {
|
||||
wipeSecretSession(s)
|
||||
}
|
||||
d.Extracted = nil
|
||||
}
|
||||
|
||||
// wipeSecretSession zeroes the session-bearing fields of a secret (cookies,
|
||||
// token, headers, query params and web storage), leaving non-sensitive scoping
|
||||
// fields (type/domains) intact. It is a no-op on nil.
|
||||
func wipeSecretSession(s *Secret) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Headers = nil
|
||||
s.Cookies = nil
|
||||
s.Params = nil
|
||||
s.Token = ""
|
||||
s.LocalStorage = nil
|
||||
s.SessionStorage = nil
|
||||
}
|
||||
|
||||
func (d *Dynamic) fetchingOnThisGoroutine() bool {
|
||||
if d == nil || d.fetchState == nil {
|
||||
return false
|
||||
|
||||
@@ -98,8 +98,8 @@ func TestDynamicUnmarshalJSON(t *testing.T) {
|
||||
require.Equal(t, "HeadersAuth", d.Type)
|
||||
require.Equal(t, []string{"api.test.com"}, d.Domains)
|
||||
require.Len(t, d.Headers, 1)
|
||||
require.Equal(t, "X-API-Key", d.Secret.Headers[0].Key)
|
||||
require.Equal(t, "secret-key", d.Secret.Headers[0].Value)
|
||||
require.Equal(t, "X-API-Key", d.Headers[0].Key)
|
||||
require.Equal(t, "secret-key", d.Headers[0].Value)
|
||||
|
||||
// Dynamic fields
|
||||
require.Equal(t, "test-template.yaml", d.TemplatePath)
|
||||
@@ -142,6 +142,8 @@ func TestDynamicFetchConcurrent(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(_ *Dynamic) error {
|
||||
// a failed login is not retried by the waiting requests: a second
|
||||
// call would close fetchStarted again and panic
|
||||
close(fetchStarted)
|
||||
<-fetchUnblock
|
||||
return wantErr
|
||||
@@ -601,7 +603,7 @@ func TestSetLazyFetchCallbackAppliesValues(t *testing.T) {
|
||||
|
||||
err := d.Fetch(false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer jwt-secret-123", d.Secret.Headers[0].Value)
|
||||
require.Equal(t, "Bearer jwt-secret-123", d.Headers[0].Value)
|
||||
})
|
||||
|
||||
t.Run("applies to secrets array", func(t *testing.T) {
|
||||
@@ -826,3 +828,63 @@ func TestGetDomainAndDomainRegex(t *testing.T) {
|
||||
require.Nil(t, regexes)
|
||||
})
|
||||
}
|
||||
|
||||
// A failed login must not be retried on every request: each retry is one more
|
||||
// login attempt against the target (account lockout, WAF bans).
|
||||
func TestDynamicFailedLoginIsNotRetriedPerRequest(t *testing.T) {
|
||||
newDynamic := func(refresh string, fail *atomic.Bool, calls *atomic.Int32) *Dynamic {
|
||||
d := &Dynamic{
|
||||
TemplatePath: "test-template.yaml",
|
||||
Variables: []KV{{Key: "username", Value: "test"}},
|
||||
RefreshInterval: refresh,
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(d *Dynamic) error {
|
||||
calls.Add(1)
|
||||
if fail.Load() {
|
||||
return errors.New("bad credentials")
|
||||
}
|
||||
d.Extracted = map[string]interface{}{"token": "x"}
|
||||
return nil
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
t.Run("first login fails", func(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
var calls atomic.Int32
|
||||
fail.Store(true)
|
||||
d := newDynamic("", &fail, &calls)
|
||||
for i := 0; i < 5; i++ {
|
||||
require.Error(t, d.Fetch(false))
|
||||
}
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("re-login after stale fails", func(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
var calls atomic.Int32
|
||||
d := newDynamic("", &fail, &calls)
|
||||
require.NoError(t, d.Fetch(false))
|
||||
fail.Store(true)
|
||||
d.MarkStale()
|
||||
for i := 0; i < 5; i++ {
|
||||
require.Error(t, d.Fetch(false))
|
||||
}
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("retried after refresh interval", func(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
var calls atomic.Int32
|
||||
fail.Store(true)
|
||||
d := newDynamic("50ms", &fail, &calls)
|
||||
require.Error(t, d.Fetch(false))
|
||||
require.Error(t, d.Fetch(false))
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
fail.Store(false)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
require.NoError(t, d.Fetch(false))
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
})
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const (
|
||||
HeadersAuth AuthType = "Header"
|
||||
CookiesAuth AuthType = "Cookie"
|
||||
QueryAuth AuthType = "Query"
|
||||
// WebStorageAuth carries browser localStorage/sessionStorage to be seeded
|
||||
// into headless scan pages. It has no HTTP representation (its Apply is a
|
||||
// no-op) and is used to replay token-in-web-storage sessions (e.g. captured
|
||||
// via auth-capture) for SPA scans.
|
||||
WebStorageAuth AuthType = "WebStorage"
|
||||
)
|
||||
|
||||
// SupportedAuthTypes returns the supported auth types
|
||||
@@ -35,6 +40,7 @@ func SupportedAuthTypes() []string {
|
||||
string(HeadersAuth),
|
||||
string(CookiesAuth),
|
||||
string(QueryAuth),
|
||||
string(WebStorageAuth),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,16 +61,20 @@ type AuthFileInfo struct {
|
||||
|
||||
// Secret is a struct for secret or credential
|
||||
type Secret struct {
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Domains []string `json:"domains" yaml:"domains"`
|
||||
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
|
||||
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
|
||||
Cookies []Cookie `json:"cookies" yaml:"cookies"`
|
||||
Params []KV `json:"params" yaml:"params"`
|
||||
Username string `json:"username" yaml:"username"` // can be either email or username
|
||||
Password string `json:"password" yaml:"password"`
|
||||
Token string `json:"token" yaml:"token"` // Bearer Auth token
|
||||
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Domains []string `json:"domains" yaml:"domains"`
|
||||
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
|
||||
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
|
||||
Cookies []Cookie `json:"cookies" yaml:"cookies"`
|
||||
Params []KV `json:"params" yaml:"params"`
|
||||
Username string `json:"username" yaml:"username"` // can be either email or username
|
||||
Password string `json:"password" yaml:"password"`
|
||||
Token string `json:"token" yaml:"token"` // Bearer Auth token
|
||||
// LocalStorage / SessionStorage carry browser web storage for WebStorageAuth
|
||||
// secrets, seeded into headless scan pages before page scripts run.
|
||||
LocalStorage map[string]string `json:"local-storage" yaml:"local-storage"`
|
||||
SessionStorage map[string]string `json:"session-storage" yaml:"session-storage"`
|
||||
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
|
||||
}
|
||||
|
||||
// GetStrategy returns the auth strategy for the secret
|
||||
@@ -80,6 +90,8 @@ func (s *Secret) GetStrategy() AuthStrategy {
|
||||
return NewCookiesAuthStrategy(s)
|
||||
case strings.EqualFold(s.Type, string(QueryAuth)):
|
||||
return NewQueryAuthStrategy(s)
|
||||
case strings.EqualFold(s.Type, string(WebStorageAuth)):
|
||||
return NewWebStorageAuthStrategy(s.LocalStorage, s.SessionStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -135,6 +147,10 @@ func (s *Secret) Validate() error {
|
||||
return fmt.Errorf("invalid cookie in cookiesAuth: %s", err)
|
||||
}
|
||||
}
|
||||
case strings.EqualFold(s.Type, string(WebStorageAuth)):
|
||||
if len(s.LocalStorage) == 0 && len(s.SessionStorage) == 0 {
|
||||
return fmt.Errorf("local-storage or session-storage cannot be empty in web storage auth")
|
||||
}
|
||||
case strings.EqualFold(s.Type, string(QueryAuth)):
|
||||
if len(s.Params) == 0 {
|
||||
return fmt.Errorf("query cannot be empty in query auth")
|
||||
@@ -169,6 +185,13 @@ type Cookie struct {
|
||||
Key string `json:"key" yaml:"key"`
|
||||
Value string `json:"value" yaml:"value"`
|
||||
Raw string `json:"raw" yaml:"raw"`
|
||||
// Domain / Path / Secure preserve the cookie's protocol scope when captured
|
||||
// (e.g. from a headless login). Empty Domain/Path means "unscoped": the
|
||||
// cookie is attached to every request the secret's domains match, matching
|
||||
// historical nuclei behaviour for manually authored secrets.
|
||||
Domain string `json:"domain,omitempty" yaml:"domain,omitempty"`
|
||||
Path string `json:"path,omitempty" yaml:"path,omitempty"`
|
||||
Secure bool `json:"secure,omitempty" yaml:"secure,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Cookie) Validate() error {
|
||||
@@ -242,6 +265,11 @@ func GetTemplatePathsFromSecretFile(file string) ([]string, error) {
|
||||
}
|
||||
var paths []string
|
||||
for _, dynamic := range auth.Dynamic {
|
||||
// Auto-login dynamics have no template path; skip them so we don't try to
|
||||
// load an empty template.
|
||||
if dynamic.TemplatePath == "" {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, dynamic.TemplatePath)
|
||||
}
|
||||
return paths, nil
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeAuthServer simulates a login + protected-resource backend with
|
||||
// server-side session expiry, to exercise the dynamic session lifecycle end to
|
||||
// end over real HTTP:
|
||||
// - GET /login issues a fresh incrementing bearer token and marks it as the
|
||||
// only currently-valid session.
|
||||
// - GET /api returns 200 when the presented bearer matches the valid session,
|
||||
// and 401 once the session has been expired server-side.
|
||||
type fakeAuthServer struct {
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
valid string // currently valid token; empty means "no valid session"
|
||||
logins atomic.Int32
|
||||
}
|
||||
|
||||
func (s *fakeAuthServer) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.counter++
|
||||
s.valid = fmt.Sprintf("sess-%d", s.counter)
|
||||
tok := s.valid
|
||||
s.mu.Unlock()
|
||||
s.logins.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok})
|
||||
})
|
||||
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
valid := s.valid
|
||||
s.mu.Unlock()
|
||||
if valid != "" && r.Header.Get("Authorization") == "Bearer "+valid {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "secret-data")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, "session expired")
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// expire invalidates the current session server-side without issuing a new one,
|
||||
// so the next /api call with the old token returns 401.
|
||||
func (s *fakeAuthServer) expire() {
|
||||
s.mu.Lock()
|
||||
s.valid = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestSessionLifecycle_E2E drives the whole dynamic-session state machine against
|
||||
// a real HTTP server: it performs a real login to mint a token, uses it on a
|
||||
// protected endpoint, lets the server expire the session, and verifies that a
|
||||
// 401 (surfaced through NotifyResponse) triggers a real re-authentication whose
|
||||
// fresh token restores access — all through the production Dynamic/strategy code.
|
||||
func TestSessionLifecycle_E2E(t *testing.T) {
|
||||
backend := &fakeAuthServer{}
|
||||
srv := httptest.NewServer(backend.handler())
|
||||
defer srv.Close()
|
||||
|
||||
httpClient := srv.Client()
|
||||
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"127.0.0.1", "localhost"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
ReauthStatusCodes: []int{401},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
|
||||
// The login callback performs a REAL HTTP login and extracts the token, the
|
||||
// same shape the template-driven fetch produces.
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
resp, err := httpClient.Get(srv.URL + "/login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
dyn.Extracted = map[string]interface{}{"token": payload.Token}
|
||||
return nil
|
||||
})
|
||||
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
inspector, ok := AuthStrategy(strategy).(ResponseInspector)
|
||||
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
|
||||
|
||||
// doAPI applies the (possibly re-authenticated) session and calls /api.
|
||||
doAPI := func() int {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api", nil)
|
||||
require.NoError(t, err)
|
||||
strategy.Apply(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
// surface the response to the auth strategy, like the http protocol does
|
||||
inspector.OnResponse(resp.StatusCode, 0)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// 1) First call logs in and succeeds.
|
||||
require.Equal(t, http.StatusOK, doAPI(), "first authenticated request should succeed")
|
||||
require.Equal(t, int32(1), backend.logins.Load(), "exactly one login so far")
|
||||
require.False(t, d.IsExpired())
|
||||
|
||||
// 2) Server expires the session out from under us → next call sees 401 and,
|
||||
// via NotifyResponse, marks the session stale.
|
||||
backend.expire()
|
||||
require.Equal(t, http.StatusUnauthorized, doAPI(), "request with the now-expired token should 401")
|
||||
require.True(t, d.IsExpired(), "401 must mark the session stale for re-authentication")
|
||||
|
||||
// 3) Next call re-authenticates (a second real login) and succeeds again.
|
||||
require.Equal(t, http.StatusOK, doAPI(), "request after re-auth should succeed with the fresh token")
|
||||
require.Equal(t, int32(2), backend.logins.Load(), "a second login should have occurred")
|
||||
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newDynamicWithToken builds a validated dynamic secret whose login callback
|
||||
// returns an incrementing token each time it runs, so re-authentication is observable.
|
||||
func newDynamicWithToken(t *testing.T, calls *atomic.Int32) *Dynamic {
|
||||
t.Helper()
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
n := calls.Add(1)
|
||||
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
|
||||
return nil
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
func tokenForCall(n int32) string {
|
||||
switch n {
|
||||
case 1:
|
||||
return "token-1"
|
||||
case 2:
|
||||
return "token-2"
|
||||
case 3:
|
||||
return "token-3"
|
||||
default:
|
||||
return "token-n"
|
||||
}
|
||||
}
|
||||
|
||||
func applyHeader(t *testing.T, d *Dynamic) string {
|
||||
t.Helper()
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
return req.Header.Get("Authorization")
|
||||
}
|
||||
|
||||
func TestSessionRefreshReRendersTemplate(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "first fetch should render initial token")
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "second apply should reuse cached session")
|
||||
require.Equal(t, int32(1), calls.Load(), "login should run once before refresh")
|
||||
|
||||
require.NoError(t, d.Refresh(false))
|
||||
require.Equal(t, int32(2), calls.Load(), "refresh should re-run the login flow")
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "refresh must re-render template with the new token")
|
||||
}
|
||||
|
||||
func TestSessionMarkStaleTriggersReauth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
|
||||
d.MarkStale()
|
||||
require.True(t, d.IsExpired(), "session should report expired after MarkStale")
|
||||
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "stale session should re-authenticate on next apply")
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
|
||||
}
|
||||
|
||||
func TestSessionRefreshIntervalExpiry(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
RefreshInterval: "40ms",
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
n := calls.Add(1)
|
||||
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
|
||||
return nil
|
||||
})
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "within interval the session is reused")
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
require.True(t, d.IsExpired(), "session should expire after the refresh interval")
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "expired session should re-authenticate")
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
}
|
||||
|
||||
func TestSessionNotifyResponseReauth(t *testing.T) {
|
||||
t.Run("configured status marks stale", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401, 403}
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
|
||||
require.False(t, d.NotifyResponse(200, 0), "2xx must not trigger re-auth")
|
||||
require.False(t, d.IsExpired())
|
||||
|
||||
require.True(t, d.NotifyResponse(401, 0), "401 must trigger re-auth for established session")
|
||||
require.True(t, d.IsExpired())
|
||||
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d))
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("no reauth before first fetch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
|
||||
require.False(t, d.NotifyResponse(401, 0), "should not mark stale before a session exists")
|
||||
require.Equal(t, int32(0), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("unconfigured status code is ignored", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
// ReauthStatusCodes left empty => response-triggered reauth disabled
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.False(t, d.NotifyResponse(401, 0))
|
||||
require.False(t, d.IsExpired())
|
||||
})
|
||||
|
||||
t.Run("stale generation is ignored", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
(&DynamicAuthStrategy{Dynamic: *d}).Apply(req)
|
||||
gen1 := SessionGenerationFromRequest(req)
|
||||
require.NotZero(t, gen1)
|
||||
|
||||
require.True(t, d.NotifyResponse(401, gen1))
|
||||
require.True(t, d.IsExpired())
|
||||
|
||||
req2, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
(&DynamicAuthStrategy{Dynamic: *d}).Apply(req2)
|
||||
gen2 := SessionGenerationFromRequest(req2)
|
||||
require.NotEqual(t, gen1, gen2)
|
||||
|
||||
// A late 401 from the previous generation must not invalidate the new session.
|
||||
require.False(t, d.NotifyResponse(401, gen1))
|
||||
require.False(t, d.IsExpired())
|
||||
require.Equal(t, "Bearer token-2", req2.Header.Get("Authorization"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionResponseInspectorInterface(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
var strategy AuthStrategy = &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
inspector, ok := strategy.(ResponseInspector)
|
||||
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
|
||||
|
||||
// establish session via the same shared state
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
require.Equal(t, "Bearer token-1", req.Header.Get("Authorization"))
|
||||
|
||||
require.True(t, inspector.OnResponse(401, 0))
|
||||
req2, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req2)
|
||||
require.Equal(t, "Bearer token-2", req2.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestSessionValidateRejectsBadRefreshInterval(t *testing.T) {
|
||||
d := &Dynamic{
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
RefreshInterval: "not-a-duration",
|
||||
}
|
||||
err := d.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid refresh-interval")
|
||||
}
|
||||
|
||||
// TestSessionConcurrentApplyDuringReauth exercises the locking contract: request
|
||||
// threads applying the strategy must never race a concurrent re-authentication.
|
||||
// Intended to be run with -race.
|
||||
func TestSessionConcurrentApplyDuringReauth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
calls.Add(1)
|
||||
// simulate some work during login so writers and readers overlap
|
||||
time.Sleep(time.Millisecond)
|
||||
dyn.Extracted = map[string]interface{}{"token": "tok"}
|
||||
return nil
|
||||
})
|
||||
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// readers continuously apply the strategy
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
// assert (not require): a failed check in a worker must not
|
||||
// call FailNow from a non-test goroutine.
|
||||
assert.Equal(t, "Bearer tok", req.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// writers continuously invalidate the session, forcing re-auth
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
d.MarkStale()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
require.Greater(t, calls.Load(), int32(1), "re-authentication should have happened multiple times")
|
||||
}
|
||||
|
||||
func TestSessionNotifyResponseCapsUnproductiveReauth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
|
||||
// the login succeeds but every request still gets 401
|
||||
for range 10 {
|
||||
applyHeader(t, d)
|
||||
d.NotifyResponse(401, 0)
|
||||
}
|
||||
require.Equal(t, int32(1+maxStatusReauths), calls.Load(), "re-authentication must stop when it does not change the response")
|
||||
require.False(t, d.IsExpired())
|
||||
|
||||
// a request that gets another status re-arms status-triggered re-auth
|
||||
require.False(t, d.NotifyResponse(200, 0))
|
||||
require.True(t, d.NotifyResponse(401, 0))
|
||||
applyHeader(t, d)
|
||||
require.Equal(t, int32(2+maxStatusReauths), calls.Load())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
@@ -15,6 +16,46 @@ type AuthStrategy interface {
|
||||
ApplyOnRR(*retryablehttp.Request)
|
||||
}
|
||||
|
||||
// ResponseInspector is an optional interface that an AuthStrategy may implement
|
||||
// to observe responses to authenticated requests. It is used to detect session
|
||||
// expiry (e.g. a 401 after a previously valid session) and trigger
|
||||
// re-authentication for subsequent requests.
|
||||
type ResponseInspector interface {
|
||||
// OnResponse is called with the status code of a response to a request the
|
||||
// strategy authenticated. sessionGeneration identifies the session that was
|
||||
// applied to that request (0 when unknown). It returns true if
|
||||
// re-authentication was triggered.
|
||||
OnResponse(statusCode int, sessionGeneration uint64) bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ AuthStrategy = &DynamicAuthStrategy{}
|
||||
_ ResponseInspector = &DynamicAuthStrategy{}
|
||||
_ BrowserStorageProvider = &DynamicAuthStrategy{}
|
||||
)
|
||||
|
||||
type authSessionGenerationKey struct{}
|
||||
|
||||
// WithSessionGeneration stores the dynamic-session generation on the request
|
||||
// context so a later response can be correlated with the session that
|
||||
// authenticated it.
|
||||
func WithSessionGeneration(req *http.Request, generation uint64) *http.Request {
|
||||
if req == nil || generation == 0 {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(context.WithValue(req.Context(), authSessionGenerationKey{}, generation))
|
||||
}
|
||||
|
||||
// SessionGenerationFromRequest returns the session generation stamped onto req,
|
||||
// or 0 when absent.
|
||||
func SessionGenerationFromRequest(req *http.Request) uint64 {
|
||||
if req == nil {
|
||||
return 0
|
||||
}
|
||||
gen, _ := req.Context().Value(authSessionGenerationKey{}).(uint64)
|
||||
return gen
|
||||
}
|
||||
|
||||
// DynamicAuthStrategy is an auth strategy for dynamic secrets
|
||||
// it implements the AuthStrategy interface
|
||||
type DynamicAuthStrategy struct {
|
||||
@@ -24,22 +65,36 @@ type DynamicAuthStrategy struct {
|
||||
|
||||
// Apply applies the strategy to the request
|
||||
func (d *DynamicAuthStrategy) Apply(req *http.Request) {
|
||||
strategies := d.Dynamic.GetStrategies()
|
||||
if strategies == nil {
|
||||
return
|
||||
}
|
||||
for _, s := range strategies {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
gen := d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
|
||||
s.Apply(req)
|
||||
})
|
||||
if req != nil && gen != 0 {
|
||||
*req = *WithSessionGeneration(req, gen)
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyOnRR applies the strategy to the retryable request
|
||||
func (d *DynamicAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
strategy := d.Dynamic.GetStrategies()
|
||||
for _, s := range strategy {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
gen := d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
|
||||
s.ApplyOnRR(req)
|
||||
})
|
||||
if gen != 0 && req.Request != nil {
|
||||
req.Request = WithSessionGeneration(req.Request, gen)
|
||||
}
|
||||
}
|
||||
|
||||
// OnResponse inspects a response status code and marks the dynamic session for
|
||||
// re-authentication when the code signals an expired session for the given
|
||||
// generation.
|
||||
func (d *DynamicAuthStrategy) OnResponse(statusCode int, sessionGeneration uint64) bool {
|
||||
return d.Dynamic.NotifyResponse(statusCode, sessionGeneration)
|
||||
}
|
||||
|
||||
// WebStorage exposes the browser web storage captured by a headless auto-login
|
||||
// so the headless engine can replay it into scan pages.
|
||||
func (d *DynamicAuthStrategy) WebStorage() (map[string]string, map[string]string) {
|
||||
return d.Dynamic.WebStorage()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
|
||||
var (
|
||||
_ AuthStrategy = &WebStorageAuthStrategy{}
|
||||
_ BrowserStorageProvider = &WebStorageAuthStrategy{}
|
||||
)
|
||||
|
||||
// BrowserStorageProvider is an optional interface implemented by auth strategies
|
||||
// that carry browser web storage (localStorage/sessionStorage) to be replayed
|
||||
// into headless scan pages. HTTP-only strategies do not implement it, so the
|
||||
// headless engine type-asserts for it before seeding storage.
|
||||
type BrowserStorageProvider interface {
|
||||
// WebStorage returns the localStorage and sessionStorage items to seed.
|
||||
WebStorage() (local map[string]string, session map[string]string)
|
||||
}
|
||||
|
||||
// WebStorageAuthStrategy carries captured web storage. Its Apply methods are
|
||||
// intentional no-ops: web storage cannot be expressed on an HTTP request, so it
|
||||
// only takes effect in the headless engine, which reads it via the
|
||||
// BrowserStorageProvider interface and seeds it into the page before page
|
||||
// scripts run.
|
||||
type WebStorageAuthStrategy struct {
|
||||
Local map[string]string
|
||||
Session map[string]string
|
||||
}
|
||||
|
||||
// NewWebStorageAuthStrategy creates a new web storage auth strategy.
|
||||
func NewWebStorageAuthStrategy(local, session map[string]string) *WebStorageAuthStrategy {
|
||||
return &WebStorageAuthStrategy{Local: local, Session: session}
|
||||
}
|
||||
|
||||
// Apply is a no-op (web storage is not an HTTP request artifact).
|
||||
func (s *WebStorageAuthStrategy) Apply(_ *http.Request) {}
|
||||
|
||||
// ApplyOnRR is a no-op (web storage is not an HTTP request artifact).
|
||||
func (s *WebStorageAuthStrategy) ApplyOnRR(_ *retryablehttp.Request) {}
|
||||
|
||||
// WebStorage returns the carried storage items.
|
||||
func (s *WebStorageAuthStrategy) WebStorage() (map[string]string, map[string]string) {
|
||||
return s.Local, s.Session
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWebStorageSecret_GetStrategy(t *testing.T) {
|
||||
s := &Secret{
|
||||
Type: string(WebStorageAuth),
|
||||
Domains: []string{"app.example.com"},
|
||||
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
require.NoError(t, s.Validate())
|
||||
|
||||
strategy := s.GetStrategy()
|
||||
require.NotNil(t, strategy)
|
||||
|
||||
bsp, ok := strategy.(BrowserStorageProvider)
|
||||
require.True(t, ok, "web storage secret must yield a BrowserStorageProvider")
|
||||
local, session := bsp.WebStorage()
|
||||
require.Equal(t, "eyJ.payload.sig", local["jwt"])
|
||||
require.Equal(t, "tok", session["csrf"])
|
||||
}
|
||||
|
||||
func TestWebStorageSecret_ValidateEmpty(t *testing.T) {
|
||||
s := &Secret{Type: string(WebStorageAuth), Domains: []string{"app.example.com"}}
|
||||
require.Error(t, s.Validate(), "web storage secret with no storage must be invalid")
|
||||
}
|
||||
|
||||
func TestWebStorageSecret_SessionOnlyValid(t *testing.T) {
|
||||
s := &Secret{
|
||||
Type: string(WebStorageAuth),
|
||||
Domains: []string{"app.example.com"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
require.NoError(t, s.Validate(), "session-storage alone should satisfy validation")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// CaptureOnce launches a visible browser at cfg.LoginURL and lets the user log
|
||||
// in manually, then — once ready(ctx) returns — captures the resulting session
|
||||
// (cookies, web storage and an optional token). It performs no automated form
|
||||
// filling.
|
||||
//
|
||||
// This is the "log in yourself, I'll grab the session" mode for one-off
|
||||
// authenticated scans of flows that are impractical to record/replay
|
||||
// (hardware-key MFA, CAPTCHA, interactive SSO). Because the captured session is
|
||||
// a point-in-time snapshot there is no automated re-authentication; ready
|
||||
// blocks until the caller signals login completion (e.g. the user pressing
|
||||
// Enter in the terminal) and should honor ctx cancellation.
|
||||
func CaptureOnce(ctx context.Context, cfg Config, ready func(context.Context) error) (*Session, error) {
|
||||
if ready == nil {
|
||||
return nil, errkit.New("auto-login(capture): a ready signal is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login(capture): login-url is required")
|
||||
}
|
||||
if _, err := url.Parse(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
var err error
|
||||
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
// Force a visible browser so the user can interact with the login page.
|
||||
cfg.ShowBrowser = true
|
||||
|
||||
browser, cleanup, err := launchBrowser(cfg)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to launch browser")
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
page, err := browser.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to open page")
|
||||
}
|
||||
defer func() { _ = page.Close() }()
|
||||
page = page.Context(ctx)
|
||||
|
||||
if cfg.UserAgent != "" {
|
||||
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
|
||||
return nil, errkit.Wrap(uaErr, "auto-login(capture): failed to set user-agent")
|
||||
}
|
||||
}
|
||||
if len(cfg.CustomHeaders) > 0 {
|
||||
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
|
||||
for k, v := range cfg.CustomHeaders {
|
||||
pairs = append(pairs, k, v)
|
||||
}
|
||||
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
|
||||
return nil, errkit.Wrap(hErr, "auto-login(capture): failed to set custom headers")
|
||||
}
|
||||
}
|
||||
|
||||
if err := page.Navigate(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to navigate to login page")
|
||||
}
|
||||
_ = page.WaitLoad()
|
||||
|
||||
// Block until the user signals that the manual login is complete.
|
||||
if err := ready(ctx); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): aborted before capture")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session := &Session{}
|
||||
if info, ierr := page.Info(); ierr == nil {
|
||||
session.FinalURL = info.URL
|
||||
}
|
||||
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
session.LocalStorage = readStorage(page, "localStorage")
|
||||
session.SessionStorage = readStorage(page, "sessionStorage")
|
||||
|
||||
if tokenRe != nil {
|
||||
var haystack strings.Builder
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
haystack.WriteString(html)
|
||||
}
|
||||
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
|
||||
for k, v := range store {
|
||||
haystack.WriteString("\n")
|
||||
haystack.WriteString(k)
|
||||
haystack.WriteString("=")
|
||||
haystack.WriteString(v)
|
||||
}
|
||||
}
|
||||
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
|
||||
// When no explicit token-regex matched, surface a JWT-shaped value from web
|
||||
// storage so a captured SPA session also authenticates HTTP scan requests.
|
||||
if session.Token == "" {
|
||||
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
|
||||
}
|
||||
|
||||
if len(session.Cookies) == 0 && session.Token == "" && len(session.LocalStorage) == 0 && len(session.SessionStorage) == 0 {
|
||||
return nil, errkit.New("auto-login(capture): no session captured (no cookies, token or web storage found)")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// requireDisplay skips headful (visible-browser) tests when no display server is
|
||||
// available, e.g. headless Linux CI. macOS and Windows always provide one, but
|
||||
// capture-once forces a visible browser which cannot start without an X/Wayland
|
||||
// display.
|
||||
func requireDisplay(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "linux" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
t.Skip("no display server ($DISPLAY/$WAYLAND_DISPLAY) available; skipping headful capture-once test")
|
||||
}
|
||||
}
|
||||
|
||||
// capturePage already "logs the user in" on GET (sets a session cookie and seeds
|
||||
// web storage via JS), so a test can drive CaptureOnce without simulating manual
|
||||
// interaction: the ready() callback simply returns once the page has loaded.
|
||||
const capturePage = `<html><head><title>App</title></head><body>
|
||||
<h1>welcome dave</h1>
|
||||
<script>
|
||||
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
|
||||
window.sessionStorage.setItem('csrf', 'abc123');
|
||||
</script></body></html>`
|
||||
|
||||
func TestCaptureOnce_E2E(t *testing.T) {
|
||||
requireChrome(t)
|
||||
requireDisplay(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, capturePage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var readyCalled bool
|
||||
session, err := CaptureOnce(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
|
||||
}, func(context.Context) error {
|
||||
readyCalled = true // stands in for the user pressing Enter after logging in
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, readyCalled, "ready callback must be invoked before capture")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Equal(t, "sess-dave", names["session"], "session cookie must be captured")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
|
||||
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted")
|
||||
}
|
||||
|
||||
func TestCaptureOnce_ReadyErrorAborts(t *testing.T) {
|
||||
requireChrome(t)
|
||||
requireDisplay(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "<html><body>login</body></html>")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := CaptureOnce(ctx, Config{LoginURL: srv.URL + "/login"}, func(context.Context) error {
|
||||
return errors.New("user cancelled")
|
||||
})
|
||||
require.Error(t, err, "a ready() error must abort the capture")
|
||||
}
|
||||
|
||||
func TestCaptureOnce_Validation(t *testing.T) {
|
||||
_, err := CaptureOnce(context.Background(), Config{LoginURL: "https://x"}, nil)
|
||||
require.Error(t, err, "nil ready must error")
|
||||
|
||||
_, err = CaptureOnce(context.Background(), Config{}, func(context.Context) error { return nil })
|
||||
require.Error(t, err, "missing login-url must error")
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package autologin implements turnkey form-based authentication for nuclei's
|
||||
// authenticated scanning. Instead of requiring a hand-written auth template that
|
||||
// performs the login and extracts a token, the caller supplies only a login URL
|
||||
// and credentials; this package fetches the login page, auto-detects the login
|
||||
// form (username/password fields plus any hidden CSRF tokens), submits the
|
||||
// credentials and captures the resulting session.
|
||||
//
|
||||
// detect.go contains the pure, network-free form-detection logic so it can be
|
||||
// unit-tested in isolation from the HTTP engine in login.go.
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoLoginForm is returned when the page contains no form with a password
|
||||
// field (the minimal signal that a form is a credential-login form).
|
||||
ErrNoLoginForm = errkit.New("no login form (password field) found on page")
|
||||
)
|
||||
|
||||
// LoginForm describes a detected HTML login form and everything needed to
|
||||
// submit it.
|
||||
type LoginForm struct {
|
||||
// Action is the absolute URL the form submits to, resolved against the page URL.
|
||||
Action string
|
||||
// Method is the upper-cased HTTP method ("POST" or "GET").
|
||||
Method string
|
||||
// EncType is the form encoding ("application/x-www-form-urlencoded" or
|
||||
// "multipart/form-data"); empty means the urlencoded default.
|
||||
EncType string
|
||||
// UsernameField is the name of the input that should receive the username.
|
||||
// It may be empty when no plausible username field is present (e.g. a
|
||||
// password-only or token form), in which case the caller may still submit.
|
||||
UsernameField string
|
||||
// PasswordField is the name of the password input. Always non-empty on a
|
||||
// successfully detected form.
|
||||
PasswordField string
|
||||
// Fields holds extra form values that must be submitted verbatim — most
|
||||
// importantly hidden inputs carrying CSRF tokens, plus any pre-filled
|
||||
// defaults and a named submit button. Username/password are NOT included
|
||||
// here; the engine fills those into UsernameField/PasswordField.
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// usernameHintTokens are substrings (matched case-insensitively against an
|
||||
// input's name/id/autocomplete) that strongly suggest a username/identifier
|
||||
// field. Ordered roughly by specificity.
|
||||
var usernameHintTokens = []string{"username", "user", "email", "e-mail", "login", "account", "userid", "uid", "identifier", "phone"}
|
||||
|
||||
// DetectLoginForm parses the given HTML page body and returns the best
|
||||
// credential-login form found on it. pageURL is the absolute URL the body was
|
||||
// fetched from and is used to resolve relative form actions. It returns
|
||||
// ErrNoLoginForm if no form containing a password field exists.
|
||||
func DetectLoginForm(body string, pageURL *url.URL) (*LoginForm, error) {
|
||||
doc, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "failed to parse html")
|
||||
}
|
||||
|
||||
var forms []*html.Node
|
||||
collectForms(doc, &forms)
|
||||
|
||||
var best *LoginForm
|
||||
bestScore := -1
|
||||
for _, formNode := range forms {
|
||||
candidate, score := buildLoginForm(formNode, pageURL)
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
// Prefer the highest-scoring form, keeping the first on ties so document
|
||||
// order acts as the tie-breaker.
|
||||
if score > bestScore {
|
||||
best = candidate
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, ErrNoLoginForm
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// collectForms walks the parse tree depth-first and appends every <form> node.
|
||||
func collectForms(n *html.Node, out *[]*html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "form" {
|
||||
*out = append(*out, n)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
collectForms(c, out)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNode is a flattened view of a form control relevant to login detection.
|
||||
type fieldNode struct {
|
||||
tag string // input, select, textarea, button
|
||||
typ string // lower-cased type attribute (inputs)
|
||||
name string
|
||||
id string
|
||||
value string
|
||||
autoComp string // autocomplete attribute
|
||||
}
|
||||
|
||||
// buildLoginForm inspects a single <form> node and, if it looks like a
|
||||
// credential-login form (contains exactly one usable password field), returns a
|
||||
// populated LoginForm together with a heuristic score used to pick the best
|
||||
// form on a page. A nil form means "not a login form".
|
||||
func buildLoginForm(formNode *html.Node, pageURL *url.URL) (*LoginForm, int) {
|
||||
var fields []fieldNode
|
||||
collectFields(formNode, &fields)
|
||||
|
||||
// Locate the password field. A form with no password field is not a
|
||||
// credential-login form; a form with several password fields is almost
|
||||
// always a registration / change-password form, so we skip it.
|
||||
passwordIdx := -1
|
||||
passwordCount := 0
|
||||
for i, f := range fields {
|
||||
if f.tag == "input" && f.typ == "password" {
|
||||
passwordCount++
|
||||
if passwordIdx == -1 {
|
||||
passwordIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if passwordIdx == -1 || passwordCount > 1 {
|
||||
return nil, 0
|
||||
}
|
||||
password := fields[passwordIdx]
|
||||
if password.name == "" {
|
||||
// A password field with no name cannot be submitted.
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
form := &LoginForm{
|
||||
Method: resolveMethod(formNode),
|
||||
EncType: strings.ToLower(strings.TrimSpace(getAttr(formNode, "enctype"))),
|
||||
PasswordField: password.name,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
form.Action = resolveAction(formNode, pageURL)
|
||||
|
||||
usernameIdx := pickUsernameField(fields, passwordIdx)
|
||||
if usernameIdx >= 0 {
|
||||
form.UsernameField = fields[usernameIdx].name
|
||||
}
|
||||
|
||||
// Carry along hidden inputs (CSRF tokens etc.), pre-filled defaults and a
|
||||
// single named submit button, but never the username/password controls.
|
||||
submitAdded := false
|
||||
for i, f := range fields {
|
||||
if i == passwordIdx || i == usernameIdx || f.name == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case f.tag == "input" && f.typ == "hidden":
|
||||
form.Fields[f.name] = f.value
|
||||
case f.tag == "input" && (f.typ == "submit" || f.typ == "image"):
|
||||
// Many backends key off the submit button name; include the first one.
|
||||
if !submitAdded && f.value != "" {
|
||||
form.Fields[f.name] = f.value
|
||||
submitAdded = true
|
||||
}
|
||||
case f.tag == "button" && (f.typ == "submit" || f.typ == ""):
|
||||
if !submitAdded && f.value != "" {
|
||||
form.Fields[f.name] = f.value
|
||||
submitAdded = true
|
||||
}
|
||||
case f.value != "" && f.typ != "checkbox" && f.typ != "radio":
|
||||
// Preserve pre-filled defaults for other text-like inputs so we don't
|
||||
// drop values the server expects.
|
||||
form.Fields[f.name] = f.value
|
||||
}
|
||||
}
|
||||
|
||||
// Score: a real login form usually has both a username and a password field;
|
||||
// CSRF/hidden tokens raise confidence further. This lets DetectLoginForm
|
||||
// prefer the actual login form over, say, a newsletter box that happens to
|
||||
// share the page.
|
||||
score := 1
|
||||
if form.UsernameField != "" {
|
||||
score += 2
|
||||
}
|
||||
if len(form.Fields) > 0 {
|
||||
score++
|
||||
}
|
||||
return form, score
|
||||
}
|
||||
|
||||
// collectFields flattens the form controls within a form node in document order.
|
||||
func collectFields(n *html.Node, out *[]fieldNode) {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode {
|
||||
switch c.Data {
|
||||
case "input", "select", "textarea", "button":
|
||||
*out = append(*out, fieldNode{
|
||||
tag: c.Data,
|
||||
typ: strings.ToLower(strings.TrimSpace(getAttr(c, "type"))),
|
||||
name: getAttr(c, "name"),
|
||||
id: getAttr(c, "id"),
|
||||
value: getAttr(c, "value"),
|
||||
autoComp: strings.ToLower(strings.TrimSpace(getAttr(c, "autocomplete"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
collectFields(c, out)
|
||||
}
|
||||
}
|
||||
|
||||
// pickUsernameField selects the most likely username/identifier field. It
|
||||
// considers only text-like inputs appearing before the password field and
|
||||
// prefers ones whose name/id/autocomplete match a known hint token; otherwise
|
||||
// it falls back to the closest text-like input preceding the password.
|
||||
func pickUsernameField(fields []fieldNode, passwordIdx int) int {
|
||||
isCandidate := func(f fieldNode) bool {
|
||||
if f.tag != "input" {
|
||||
return false
|
||||
}
|
||||
switch f.typ {
|
||||
case "", "text", "email", "tel", "search":
|
||||
return f.name != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// First pass: hint-token match among candidates preceding the password field.
|
||||
bestHint := -1
|
||||
bestHintRank := len(usernameHintTokens)
|
||||
for i := 0; i < passwordIdx; i++ {
|
||||
f := fields[i]
|
||||
if !isCandidate(f) {
|
||||
continue
|
||||
}
|
||||
hay := strings.ToLower(f.name + " " + f.id + " " + f.autoComp)
|
||||
for rank, tok := range usernameHintTokens {
|
||||
if strings.Contains(hay, tok) && rank < bestHintRank {
|
||||
bestHint = i
|
||||
bestHintRank = rank
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestHint >= 0 {
|
||||
return bestHint
|
||||
}
|
||||
|
||||
// Second pass: closest text-like input before the password field.
|
||||
for i := passwordIdx - 1; i >= 0; i-- {
|
||||
if isCandidate(fields[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: first text-like input anywhere in the form (covers oddly
|
||||
// ordered markup where the username sits after the password).
|
||||
for i := range fields {
|
||||
if i == passwordIdx {
|
||||
continue
|
||||
}
|
||||
if isCandidate(fields[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// resolveMethod returns the upper-cased form method, defaulting to POST. The
|
||||
// HTML default is GET, but credential-login forms virtually always POST, and a
|
||||
// GET login would leak credentials into the URL/query — so for an autonomous
|
||||
// login submitter POST is the safer default when unspecified.
|
||||
func resolveMethod(formNode *html.Node) string {
|
||||
m := strings.ToUpper(strings.TrimSpace(getAttr(formNode, "method")))
|
||||
if m == "" {
|
||||
return "POST"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveAction resolves the form action against the page URL. An empty/missing
|
||||
// action submits back to the page itself.
|
||||
func resolveAction(formNode *html.Node, pageURL *url.URL) string {
|
||||
action := strings.TrimSpace(getAttr(formNode, "action"))
|
||||
if action == "" {
|
||||
if pageURL != nil {
|
||||
return pageURL.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if pageURL == nil {
|
||||
return action
|
||||
}
|
||||
ref, err := url.Parse(action)
|
||||
if err != nil {
|
||||
return action
|
||||
}
|
||||
return pageURL.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
// getAttr returns the value of the named attribute (case-insensitive) on an
|
||||
// element node, or "" if absent.
|
||||
func getAttr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, key) {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_Simple(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://app.example.com/auth"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "POST", form.Method)
|
||||
require.Equal(t, "https://app.example.com/login", form.Action)
|
||||
require.Equal(t, "username", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_EmailAndCSRF(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="https://id.example.com/sessions" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="abc123">
|
||||
<input type="email" name="email" autocomplete="username">
|
||||
<input type="password" name="pass">
|
||||
<input type="submit" name="commit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://id.example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://id.example.com/sessions", form.Action)
|
||||
require.Equal(t, "email", form.UsernameField)
|
||||
require.Equal(t, "pass", form.PasswordField)
|
||||
require.Equal(t, "abc123", form.Fields["csrf_token"], "CSRF hidden input must be carried")
|
||||
require.Equal(t, "Sign in", form.Fields["commit"], "named submit button should be carried")
|
||||
require.NotContains(t, form.Fields, "email", "username field must not be in extra fields")
|
||||
require.NotContains(t, form.Fields, "pass", "password field must not be in extra fields")
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_RelativeActionResolution(t *testing.T) {
|
||||
body := `<form action="../session" method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/app/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/session", form.Action)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_MissingActionUsesPageURL(t *testing.T) {
|
||||
body := `<form method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login?next=/home"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/login?next=/home", form.Action)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_DefaultMethodIsPost(t *testing.T) {
|
||||
body := `<form action="/login">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "POST", form.Method)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_PicksLoginFormAmongMany(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="/search" method="get">
|
||||
<input type="text" name="q">
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<form action="/newsletter" method="post">
|
||||
<input type="email" name="newsletter_email">
|
||||
<input type="submit" value="Subscribe">
|
||||
</form>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="_token" value="xyz">
|
||||
<input type="text" name="login" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/login", form.Action)
|
||||
require.Equal(t, "login", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
require.Equal(t, "xyz", form.Fields["_token"])
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_UsernameHintBeatsOrder(t *testing.T) {
|
||||
// Two text inputs precede the password; the one with a username hint should
|
||||
// win even though it is not the closest to the password field.
|
||||
body := `<form action="/login" method="post">
|
||||
<input type="text" name="email" autocomplete="username">
|
||||
<input type="text" name="captcha">
|
||||
<input type="password" name="password">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "email", form.UsernameField)
|
||||
require.NotContains(t, form.Fields, "captcha", "valueless non-username text input should not be injected")
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_NoPasswordField(t *testing.T) {
|
||||
body := `<form action="/search" method="get">
|
||||
<input type="text" name="q">
|
||||
<input type="submit" value="Search">
|
||||
</form>`
|
||||
|
||||
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_SkipsMultiPasswordForms(t *testing.T) {
|
||||
// A registration / change-password form (two password fields) must not be
|
||||
// treated as a login form.
|
||||
body := `<form action="/register" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
<input type="password" name="password_confirm">
|
||||
</form>`
|
||||
|
||||
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_PasswordOnlyForm(t *testing.T) {
|
||||
// A password-only form (no username) is still a valid login form.
|
||||
body := `<form action="/unlock" method="post">
|
||||
<input type="hidden" name="token" value="t">
|
||||
<input type="password" name="password">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/unlock"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
require.Equal(t, "t", form.Fields["token"])
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_DefaultTypeIsText(t *testing.T) {
|
||||
// An input with no type attribute defaults to text and should be eligible as
|
||||
// the username field.
|
||||
body := `<form action="/login" method="post">
|
||||
<input name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user", form.UsernameField)
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/input"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
fileutil "github.com/projectdiscovery/utils/file"
|
||||
osutils "github.com/projectdiscovery/utils/os"
|
||||
)
|
||||
|
||||
// LoginHeadless performs a browser-driven auto-login using go-rod. Unlike the
|
||||
// HTTP engine (Login), it executes JavaScript, so it works on SPA/JS-rendered
|
||||
// login pages, client-side form construction and multi-step / SSO redirect
|
||||
// flows that the static HTTP form submitter cannot handle. It navigates to the
|
||||
// login page in a real (headless) browser, fills the detected credential
|
||||
// fields, submits, waits for the page to settle and captures the resulting
|
||||
// session (cookies, plus an optional token from the body or localStorage).
|
||||
//
|
||||
// It intentionally drives go-rod directly rather than nuclei's headless engine
|
||||
// package: that package imports authprovider, and autologin is (transitively)
|
||||
// imported by authprovider, so depending on it would create an import cycle.
|
||||
func LoginHeadless(ctx context.Context, cfg Config) (*Session, error) {
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login(headless): login-url is required")
|
||||
}
|
||||
loginURL, err := url.Parse(cfg.LoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
settle := cfg.SettleTime
|
||||
if settle == 0 {
|
||||
settle = 5 * time.Second
|
||||
}
|
||||
|
||||
browser, cleanup, err := launchBrowser(cfg)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to launch browser")
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Use an incognito context for a clean, isolated cookie jar per login.
|
||||
incognito, err := browser.Incognito()
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to create incognito context")
|
||||
}
|
||||
|
||||
page, err := incognito.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to open page")
|
||||
}
|
||||
defer func() { _ = page.Close() }()
|
||||
page = page.Context(ctx).Timeout(timeout)
|
||||
|
||||
// Passively observe response headers so a token delivered via a response
|
||||
// header (Authorization, X-Auth-Token, ...) during an XHR login is captured,
|
||||
// mirroring the HTTP engine. This is observation-only (no request hijacking)
|
||||
// to avoid interfering with the login flow. The listener stops when the page
|
||||
// is closed (deferred above).
|
||||
//
|
||||
// Two safeguards keep this from latching onto the wrong token: we only honor
|
||||
// responses served by the login origin (a page loads many third-party
|
||||
// requests — CDNs, analytics — whose headers must not be mistaken for the
|
||||
// app's session token), and we keep the first match (first-party auth
|
||||
// responses come early; a later beacon must not clobber the real token).
|
||||
var (
|
||||
headerTokenMu sync.Mutex
|
||||
headerToken string
|
||||
)
|
||||
if err := (proto.NetworkEnable{}).Call(page); err == nil {
|
||||
go page.EachEvent(func(e *proto.NetworkResponseReceived) {
|
||||
if e.Response == nil {
|
||||
return
|
||||
}
|
||||
if !sameLoginOrigin(loginURL, e.Response.URL) {
|
||||
return
|
||||
}
|
||||
hdr := make(http.Header, len(e.Response.Headers))
|
||||
for k, v := range e.Response.Headers {
|
||||
hdr.Set(k, v.Str())
|
||||
}
|
||||
if tok := detectHeaderToken(hdr, tokenRe); tok != "" {
|
||||
headerTokenMu.Lock()
|
||||
if headerToken == "" {
|
||||
headerToken = tok
|
||||
}
|
||||
headerTokenMu.Unlock()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Block a 307/308 replay of the login form submit (which carries the
|
||||
// password) to another origin, as the HTTP engine does. It must be in place
|
||||
// before the first navigation.
|
||||
var replayGuard *redirectReplayGuard
|
||||
if !cfg.AllowCrossOriginAction {
|
||||
if replayGuard, err = guardRedirectReplay(page); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to enable request interception")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply UA / custom headers before navigating so the login request carries
|
||||
// the same identity as the scan.
|
||||
if cfg.UserAgent != "" {
|
||||
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
|
||||
return nil, errkit.Wrap(uaErr, "auto-login(headless): failed to set user-agent")
|
||||
}
|
||||
}
|
||||
if len(cfg.CustomHeaders) > 0 {
|
||||
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
|
||||
for k, v := range cfg.CustomHeaders {
|
||||
pairs = append(pairs, k, v)
|
||||
}
|
||||
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
|
||||
return nil, errkit.Wrap(hErr, "auto-login(headless): failed to set custom headers")
|
||||
}
|
||||
}
|
||||
|
||||
if err := page.Navigate(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to navigate to login page")
|
||||
}
|
||||
if err := page.WaitLoad(); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): login page failed to load")
|
||||
}
|
||||
// Give client-side scripts a chance to render the form.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
|
||||
if len(cfg.Steps) > 0 {
|
||||
// Explicit multi-step flow (username-first / SSO / consent screens).
|
||||
if err := runLoginSteps(ctx, page, cfg, settle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := autoFillAndSubmit(ctx, page, cfg, loginURL, settle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for the post-submit navigation / SPA state to settle.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
_ = page.WaitLoad()
|
||||
if blocked := replayGuard.blockedURL(); blocked != "" {
|
||||
return nil, errkit.Newf("auto-login(headless): refused to replay the login form to a different origin (%s); set allow-cross-origin-action to send the credentials there", blocked)
|
||||
}
|
||||
|
||||
session := &Session{}
|
||||
if info, ierr := page.Info(); ierr == nil {
|
||||
session.FinalURL = info.URL
|
||||
}
|
||||
|
||||
// Capture cookies and web storage from the browser for the login + final hosts.
|
||||
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
session.LocalStorage = readStorage(page, "localStorage")
|
||||
session.SessionStorage = readStorage(page, "sessionStorage")
|
||||
|
||||
// Token extraction: search the rendered body and web storage.
|
||||
if tokenRe != nil {
|
||||
var haystack strings.Builder
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
haystack.WriteString(html)
|
||||
}
|
||||
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
|
||||
for k, v := range store {
|
||||
haystack.WriteString("\n")
|
||||
haystack.WriteString(k)
|
||||
haystack.WriteString("=")
|
||||
haystack.WriteString(v)
|
||||
}
|
||||
}
|
||||
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: many SPAs keep their bearer token in web storage (e.g.
|
||||
// localStorage["token"]) rather than a cookie. When no explicit token-regex
|
||||
// matched, surface a JWT-shaped value from web storage as the session token
|
||||
// so HTTP scan requests carry an Authorization header without the user
|
||||
// having to specify a token-regex.
|
||||
if session.Token == "" {
|
||||
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
|
||||
}
|
||||
|
||||
// Last resort: a token observed in a response header during the login (e.g.
|
||||
// an XHR auth call that returned Authorization / X-Auth-Token).
|
||||
if session.Token == "" {
|
||||
headerTokenMu.Lock()
|
||||
session.Token = headerToken
|
||||
headerTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// Success heuristic: being re-prompted (final page still presents a login
|
||||
// form) with no token means the login failed.
|
||||
if session.Token == "" {
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
var finalParsed *url.URL
|
||||
if session.FinalURL != "" {
|
||||
finalParsed, _ = url.Parse(session.FinalURL)
|
||||
}
|
||||
if _, derr := DetectLoginForm(html, finalParsed); derr == nil {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form at %s", session.FinalURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(session.Cookies) == 0 && session.Token == "" && len(session.LocalStorage) == 0 && len(session.SessionStorage) == 0 {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "no cookies, token or web storage captured at %s", session.FinalURL)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// redirectReplayGuard fails redirected requests that would replay a request
|
||||
// body to another origin. It sees every request of the login page at the
|
||||
// request stage (CDP Fetch), so it also covers the browser's own redirects.
|
||||
type redirectReplayGuard struct {
|
||||
mu sync.Mutex
|
||||
urls map[proto.FetchRequestID]string
|
||||
blocked string
|
||||
}
|
||||
|
||||
// guardRedirectReplay enables request-stage interception on page. Each paused
|
||||
// request continues unchanged, except a redirected request that keeps a body
|
||||
// (a 307/308 of a POST, such as the login form submit with the password) to a
|
||||
// different origin than the request that was redirected: that one fails before
|
||||
// it leaves the browser. Interception is only used for the auto-login page.
|
||||
func guardRedirectReplay(page *rod.Page) (*redirectReplayGuard, error) {
|
||||
err := proto.FetchEnable{Patterns: []*proto.FetchRequestPattern{{
|
||||
URLPattern: "*",
|
||||
RequestStage: proto.FetchRequestStageRequest,
|
||||
}}}.Call(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &redirectReplayGuard{urls: map[proto.FetchRequestID]string{}}
|
||||
go page.EachEvent(func(e *proto.FetchRequestPaused) {
|
||||
if e.Request == nil {
|
||||
_ = proto.FetchContinueRequest{RequestID: e.RequestID}.Call(page)
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.urls[e.RequestID] = e.Request.URL
|
||||
from, known := g.urls[e.RedirectedRequestID]
|
||||
g.mu.Unlock()
|
||||
if e.RedirectedRequestID != "" && blockRedirectReplay(e.Request.Method, from, known, e.Request.URL) {
|
||||
g.mu.Lock()
|
||||
if g.blocked == "" {
|
||||
g.blocked = redactURL(e.Request.URL)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
gologger.Warning().Msgf("auto-login(headless): blocked a %s redirect of the login form to a different origin (%s)", e.Request.Method, redactURL(e.Request.URL))
|
||||
_ = proto.FetchFailRequest{RequestID: e.RequestID, ErrorReason: proto.NetworkErrorReasonBlockedByClient}.Call(page)
|
||||
return
|
||||
}
|
||||
_ = proto.FetchContinueRequest{RequestID: e.RequestID}.Call(page)
|
||||
})()
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// blockedURL returns the first blocked redirect target, or "" when nothing was
|
||||
// blocked (or the guard is off).
|
||||
func (g *redirectReplayGuard) blockedURL() string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.blocked
|
||||
}
|
||||
|
||||
// blockRedirectReplay decides the headless counterpart of checkLoginRedirect:
|
||||
// a redirect that turns into GET/HEAD drops the body and is allowed; one that
|
||||
// keeps another method (307/308) is allowed only to the same origin as the
|
||||
// request that redirected. An unknown source fails closed.
|
||||
func blockRedirectReplay(method, from string, known bool, to string) bool {
|
||||
if method == http.MethodGet || method == http.MethodHead {
|
||||
return false
|
||||
}
|
||||
if !known {
|
||||
return true
|
||||
}
|
||||
fromURL, err := url.Parse(from)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
toURL, err := url.Parse(to)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return !sameOrigin(fromURL, toURL)
|
||||
}
|
||||
|
||||
func redactURL(raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
return u.Redacted()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// sameLoginOrigin reports whether respURL shares the login page's origin
|
||||
// (scheme + host[:port]). It is used to ignore third-party (CDN/analytics)
|
||||
// responses when passively scavenging a session token from response headers.
|
||||
// An unparsable respURL is treated as not matching (fail closed).
|
||||
func sameLoginOrigin(loginURL *url.URL, respURL string) bool {
|
||||
if loginURL == nil || respURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(respURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Scheme, loginURL.Scheme) && strings.EqualFold(u.Host, loginURL.Host)
|
||||
}
|
||||
|
||||
// jwtValueRe matches a JWT-shaped value (header.payload.signature in base64url).
|
||||
// It is intentionally not anchored so it also matches tokens wrapped in quotes
|
||||
// or embedded in a small JSON blob stored in web storage.
|
||||
var jwtValueRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}`)
|
||||
|
||||
// detectStorageJWT returns the first JWT-shaped value found across the supplied
|
||||
// web storage maps, or "" when none is present. Keys that conventionally hold an
|
||||
// auth token are inspected first (and otherwise keys are visited in sorted order)
|
||||
// so the result is deterministic across runs.
|
||||
func detectStorageJWT(stores ...map[string]string) string {
|
||||
preferredKeys := []string{"token", "access_token", "accesstoken", "jwt", "auth_token", "authtoken", "id_token", "idtoken", "bearer"}
|
||||
for _, store := range stores {
|
||||
if len(store) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, want := range preferredKeys {
|
||||
for k, v := range store {
|
||||
if strings.EqualFold(k, want) {
|
||||
if m := jwtValueRe.FindString(v); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, store := range stores {
|
||||
if len(store) == 0 {
|
||||
continue
|
||||
}
|
||||
keys := make([]string, 0, len(store))
|
||||
for k := range store {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if m := jwtValueRe.FindString(store[k]); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// launchBrowser launches (or connects to) a browser and returns it with a
|
||||
// cleanup func. A system-installed Chrome is preferred when available to avoid
|
||||
// triggering a managed-browser download.
|
||||
func launchBrowser(cfg Config) (*rod.Browser, func(), error) {
|
||||
if cfg.CDPEndpoint != "" {
|
||||
b := rod.New().ControlURL(cfg.CDPEndpoint)
|
||||
if err := b.Connect(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return b, func() { _ = b.Close() }, nil
|
||||
}
|
||||
|
||||
l := launcher.New().
|
||||
Set("disable-gpu").
|
||||
Set("disable-notifications").
|
||||
Set("ignore-certificate-errors").
|
||||
Set("ignore-ssl-errors").
|
||||
Set("incognito").
|
||||
Headless(!cfg.ShowBrowser).
|
||||
Leakless(false)
|
||||
if osutils.IsLinux() {
|
||||
l = l.NoSandbox(true)
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
l = l.Proxy(cfg.Proxy)
|
||||
}
|
||||
// Mirror nuclei's headless engine browser selection: on musl (most likely
|
||||
// Alpine) the managed Chromium download cannot run, so a system Chrome is
|
||||
// required — as it is when the user forces it. Otherwise prefer a system
|
||||
// Chrome when present and fall back to go-rod's managed browser, logging so a
|
||||
// one-time download is not surprising in a server/CI context.
|
||||
executablePath, _ := os.Executable()
|
||||
useMusl, _ := fileutil.UseMusl(executablePath)
|
||||
if path, ok := launcher.LookPath(); ok {
|
||||
l = l.Bin(path)
|
||||
} else if cfg.UseInstalledChrome || useMusl {
|
||||
return nil, nil, errkit.New("auto-login(headless): no chrome/chromium binary found (a system install is required here)")
|
||||
} else {
|
||||
gologger.Info().Msgf("auto-login(headless): no system chrome found, using go-rod managed browser (a one-time download may occur)")
|
||||
}
|
||||
|
||||
controlURL, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
b := rod.New().ControlURL(controlURL)
|
||||
if err := b.Connect(); err != nil {
|
||||
l.Kill()
|
||||
return nil, nil, err
|
||||
}
|
||||
return b, func() { _ = b.Close(); l.Kill() }, nil
|
||||
}
|
||||
|
||||
// autoFillAndSubmit runs the default single-shot login: detect the password
|
||||
// (and username) field, type the credentials and submit.
|
||||
func autoFillAndSubmit(ctx context.Context, page *rod.Page, cfg Config, loginURL *url.URL, settle time.Duration) error {
|
||||
// SPA forms are frequently injected after an async tick, so the password
|
||||
// field may not exist immediately after the initial settle. Wait (bounded by
|
||||
// settle) for it to appear before detecting, rather than failing outright.
|
||||
_ = waitVisible(ctx, page, `input[type="password"]`, settle)
|
||||
|
||||
// Prefer field names detected from the rendered DOM; fall back to type-based
|
||||
// selectors for SPA inputs that carry no name attribute.
|
||||
var detected *LoginForm
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
detected, _ = DetectLoginForm(html, loginURL)
|
||||
}
|
||||
|
||||
passEl := locatePasswordField(page, cfg, detected)
|
||||
if passEl == nil {
|
||||
return errkit.Wrap(ErrNoLoginForm, "auto-login(headless): no password field found in rendered page")
|
||||
}
|
||||
if userEl := locateUsernameField(page, cfg, detected); userEl != nil && cfg.Username != "" {
|
||||
if err := typeInto(userEl, cfg.Username); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to type username")
|
||||
}
|
||||
}
|
||||
if err := typeInto(passEl, cfg.Password); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to type password")
|
||||
}
|
||||
// Let the framework register the typed input and validate the form (which
|
||||
// often gates the submit button) before attempting to submit.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
return submitForm(page, passEl, settle)
|
||||
}
|
||||
|
||||
// submitForm submits a login form. It prefers clicking a submit control, but
|
||||
// frameworks frequently keep that control disabled until the form validates, so
|
||||
// the click is bounded by interactable-wait: if the button never becomes
|
||||
// interactable (or the click fails) it falls back to pressing Enter in the
|
||||
// given field, which reliably triggers native/framework form submission.
|
||||
func submitForm(page *rod.Page, fallbackField *rod.Element, settle time.Duration) error {
|
||||
clickTimeout := settle
|
||||
if clickTimeout <= 0 {
|
||||
clickTimeout = 5 * time.Second
|
||||
}
|
||||
if btn := findVisible(page, `button[type="submit"]`, `input[type="submit"]`, `button:not([type])`, `[role="button"]`); btn != nil {
|
||||
// Bound the interactable-wait so a permanently-disabled button can't hang
|
||||
// the whole login; on failure we fall through to Enter-to-submit.
|
||||
clickErr := rod.Try(func() {
|
||||
b := btn.Timeout(clickTimeout)
|
||||
b.MustWaitInteractable()
|
||||
_ = b.ScrollIntoView()
|
||||
b.MustClick()
|
||||
})
|
||||
if clickErr == nil {
|
||||
return nil
|
||||
}
|
||||
if fallbackField == nil {
|
||||
return errkit.Wrapf(ErrLoginFailed, "auto-login(headless): submit button never became interactable: %s", clickErr)
|
||||
}
|
||||
// fall through to Enter-to-submit
|
||||
}
|
||||
if fallbackField != nil {
|
||||
if err := fallbackField.Type(input.Enter); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to submit form")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errkit.Wrap(ErrLoginFailed, "auto-login(headless): no submit control found and no field to submit")
|
||||
}
|
||||
|
||||
// runLoginSteps executes an explicit multi-step login flow.
|
||||
func runLoginSteps(ctx context.Context, page *rod.Page, cfg Config, settle time.Duration) error {
|
||||
for i, step := range cfg.Steps {
|
||||
action := strings.ToLower(strings.TrimSpace(step.Action))
|
||||
switch action {
|
||||
case "navigate":
|
||||
if err := page.Navigate(step.Value); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (navigate): failed", i)
|
||||
}
|
||||
_ = page.WaitLoad()
|
||||
case "fill", "input", "type":
|
||||
el := findVisible(page, byName(step.Selector), step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (fill): element not found: %s", i, step.Selector)
|
||||
}
|
||||
if err := typeInto(el, expandCredentials(step.Value, cfg)); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (fill): failed", i)
|
||||
}
|
||||
case "click":
|
||||
el := findVisible(page, step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (click): element not found: %s", i, step.Selector)
|
||||
}
|
||||
_ = el.ScrollIntoView()
|
||||
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (click): failed", i)
|
||||
}
|
||||
case "waitvisible":
|
||||
if err := waitVisible(ctx, page, step.Selector, settle); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (waitvisible)", i)
|
||||
}
|
||||
case "wait":
|
||||
d := settle
|
||||
if step.Value != "" {
|
||||
if pd, perr := time.ParseDuration(step.Value); perr == nil {
|
||||
d = pd
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
case "press":
|
||||
key, kerr := keyFromName(step.Value)
|
||||
if kerr != nil {
|
||||
return errkit.Wrapf(kerr, "login step %d (press)", i)
|
||||
}
|
||||
if step.Selector != "" {
|
||||
el := findVisible(page, byName(step.Selector), step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (press): element not found: %s", i, step.Selector)
|
||||
}
|
||||
if err := el.Type(key); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (press): failed", i)
|
||||
}
|
||||
} else if err := page.Keyboard.Type(key); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (press): failed", i)
|
||||
}
|
||||
case "submit":
|
||||
if err := submitForm(page, findVisible(page, `input[type="password"]`), settle); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (submit)", i)
|
||||
}
|
||||
default:
|
||||
return errkit.Newf("login step %d: unknown action %q", i, step.Action)
|
||||
}
|
||||
// Let the page settle between steps so the next selector is present.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandCredentials substitutes the {{username}}/{{password}} placeholders in a
|
||||
// step value with the configured credentials.
|
||||
func expandCredentials(value string, cfg Config) string {
|
||||
value = strings.ReplaceAll(value, "{{username}}", cfg.Username)
|
||||
value = strings.ReplaceAll(value, "{{password}}", cfg.Password)
|
||||
return value
|
||||
}
|
||||
|
||||
// keyFromName maps a friendly key name to a go-rod input key.
|
||||
func keyFromName(name string) (input.Key, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "", "enter", "return":
|
||||
return input.Enter, nil
|
||||
case "tab":
|
||||
return input.Tab, nil
|
||||
case "escape", "esc":
|
||||
return input.Escape, nil
|
||||
case "space":
|
||||
return input.Space, nil
|
||||
default:
|
||||
return 0, errkit.Newf("unsupported key %q (supported: enter, tab, escape, space)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// waitVisible polls until the selector becomes visible or the timeout elapses.
|
||||
func waitVisible(ctx context.Context, page *rod.Page, selector string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if findVisible(page, selector) != nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return errkit.Newf("timeout waiting for element to become visible: %s", selector)
|
||||
}
|
||||
|
||||
// locatePasswordField finds the password input, honoring an explicit override
|
||||
// (treated as a field name first, then as a raw CSS selector), then the
|
||||
// detected name, then a generic selector.
|
||||
func locatePasswordField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
|
||||
if cfg.PasswordField != "" {
|
||||
if el := findVisible(page, byName(cfg.PasswordField), cfg.PasswordField); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
if detected != nil && detected.PasswordField != "" {
|
||||
if el := findVisible(page, byName(detected.PasswordField)); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
return findVisible(page, `input[type="password"]`)
|
||||
}
|
||||
|
||||
// locateUsernameField finds the username/identifier input using the same
|
||||
// override/detected/heuristic precedence as the password field.
|
||||
func locateUsernameField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
|
||||
if cfg.UsernameField != "" {
|
||||
if el := findVisible(page, byName(cfg.UsernameField), cfg.UsernameField); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
if detected != nil && detected.UsernameField != "" {
|
||||
if el := findVisible(page, byName(detected.UsernameField)); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
return findVisible(page,
|
||||
`input[type="email"]`,
|
||||
`input[autocomplete="username"]`,
|
||||
`input[name*="user" i]`,
|
||||
`input[name*="email" i]`,
|
||||
`input[name*="login" i]`,
|
||||
`input[type="text"]`,
|
||||
)
|
||||
}
|
||||
|
||||
// byName builds a CSS attribute selector matching an input by its name.
|
||||
func byName(name string) string {
|
||||
return fmt.Sprintf(`input[name=%q]`, name)
|
||||
}
|
||||
|
||||
// findVisible returns the first visible element matching any of the selectors
|
||||
// in priority order, or nil if none match.
|
||||
func findVisible(page *rod.Page, selectors ...string) *rod.Element {
|
||||
for _, sel := range selectors {
|
||||
els, err := queryElements(page, sel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, el := range els {
|
||||
if vis, verr := el.Visible(); verr == nil && vis {
|
||||
return el
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryElements resolves a selector that may be CSS (default) or XPath. XPath
|
||||
// selectors are prefixed with "xpath=" (emitted by the recording importer) or
|
||||
// written as a raw "//"/"(//" expression. This lets recorded login flows, whose
|
||||
// most stable selector is often an XPath, replay reliably.
|
||||
func queryElements(page *rod.Page, sel string) (rod.Elements, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(sel, "xpath="):
|
||||
return page.ElementsX(strings.TrimPrefix(sel, "xpath="))
|
||||
case strings.HasPrefix(sel, "//"), strings.HasPrefix(sel, "(//"):
|
||||
return page.ElementsX(sel)
|
||||
default:
|
||||
return page.Elements(sel)
|
||||
}
|
||||
}
|
||||
|
||||
// typeInto focuses the element, clears any existing value and types the text so
|
||||
// that JS frameworks observing input events (e.g. React controlled inputs) pick
|
||||
// up the change.
|
||||
func typeInto(el *rod.Element, text string) error {
|
||||
_ = el.ScrollIntoView()
|
||||
if err := el.Focus(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear any pre-filled value by selecting all then overwriting.
|
||||
_ = el.SelectAllText()
|
||||
return el.Input(text)
|
||||
}
|
||||
|
||||
// capturePageCookies pulls cookies the browser holds for the given URLs and
|
||||
// converts them to net/http cookies, de-duplicated by name+domain+path.
|
||||
func capturePageCookies(page *rod.Page, urls ...string) []*http.Cookie {
|
||||
filtered := urls[:0]
|
||||
for _, u := range urls {
|
||||
if u != "" {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
cookies, err := page.Cookies(filtered)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []*http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
key := c.Name + "\x00" + c.Domain + "\x00" + c.Path
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, &http.Cookie{Name: c.Name, Value: c.Value, Domain: c.Domain, Path: c.Path, HttpOnly: c.HTTPOnly, Secure: c.Secure})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readStorage returns the given web storage area (localStorage/sessionStorage)
|
||||
// as a map, or nil on failure.
|
||||
func readStorage(page *rod.Page, area string) map[string]string {
|
||||
obj, err := page.Eval(fmt.Sprintf(`() => JSON.stringify(window.%s)`, area))
|
||||
if err != nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
raw := obj.Value.Str()
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlockRedirectReplay(t *testing.T) {
|
||||
const a, b = "http://127.0.0.1:8001/do-login", "http://127.0.0.1:8002/steal"
|
||||
require.True(t, blockRedirectReplay(http.MethodPost, a, true, b), "cross-origin 307/308 POST must be blocked")
|
||||
require.False(t, blockRedirectReplay(http.MethodPost, a, true, "http://127.0.0.1:8001/next"), "same-origin 307/308 POST is allowed")
|
||||
require.False(t, blockRedirectReplay(http.MethodGet, a, true, b), "a redirect that became GET carries no body")
|
||||
require.False(t, blockRedirectReplay(http.MethodHead, a, true, b))
|
||||
require.True(t, blockRedirectReplay(http.MethodPut, a, true, b))
|
||||
require.True(t, blockRedirectReplay(http.MethodPost, "", false, b), "unknown redirect source fails closed")
|
||||
require.True(t, blockRedirectReplay(http.MethodPost, "http://127.0.0.1:8001/x", true, "https://127.0.0.1:8001/x"), "scheme change is a different origin")
|
||||
}
|
||||
|
||||
// redirectApps starts a login origin whose form submit answers 307 to target
|
||||
// (resolved lazily), and a second origin that counts POSTs it receives.
|
||||
func redirectApps(t *testing.T, sameOriginTarget bool) (login *httptest.Server, other *httptest.Server, posts *atomic.Int32) {
|
||||
t.Helper()
|
||||
posts = &atomic.Int32{}
|
||||
other = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "other", Path: "/"})
|
||||
_, _ = w.Write([]byte("<html><body>welcome</body></html>"))
|
||||
}))
|
||||
t.Cleanup(other.Close)
|
||||
login = httptest.NewServer(nil)
|
||||
login.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login":
|
||||
_, _ = w.Write([]byte(`<html><body><form method="post" action="/do-login"><input name="username"><input type="password" name="password"><button type="submit">Sign in</button></form></body></html>`))
|
||||
case "/do-login":
|
||||
target := other.URL + "/steal"
|
||||
if sameOriginTarget {
|
||||
target = login.URL + "/final"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
|
||||
case "/final":
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
_, _ = w.Write([]byte("<html><body>welcome</body></html>"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
t.Cleanup(login.Close)
|
||||
return login, other, posts
|
||||
}
|
||||
|
||||
func TestLoginHeadless_BlocksCrossOriginRedirectReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
login, _, posts := redirectApps(t, false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: login.URL + "/login",
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "different origin")
|
||||
require.Zero(t, posts.Load(), "the login POST must not reach the other origin")
|
||||
}
|
||||
|
||||
func TestLoginHeadless_AllowsSameOriginRedirectReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
login, _, posts := redirectApps(t, true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: login.URL + "/login",
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
require.Zero(t, posts.Load())
|
||||
}
|
||||
|
||||
func TestLoginHeadless_AllowCrossOriginActionPermitsReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
login, _, posts := redirectApps(t, false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, _ = LoginHeadless(ctx, Config{
|
||||
LoginURL: login.URL + "/login",
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
AllowCrossOriginAction: true,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.Equal(t, int32(1), posts.Load(), "with allow-cross-origin-action the browser follows the 307")
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// requireChrome skips the test unless a system Chrome/Chromium is installed, so
|
||||
// CI without a managed-browser download does not fail or hang.
|
||||
func requireChrome(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless auto-login test")
|
||||
}
|
||||
}
|
||||
|
||||
// jsLoginApp serves a login form that is constructed entirely by JavaScript:
|
||||
// the raw HTML has no <form>, so the static HTTP engine cannot detect or submit
|
||||
// it — only a real browser that executes JS can. This is the canonical case
|
||||
// that motivates headless login.
|
||||
type jsLoginApp struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]bool
|
||||
}
|
||||
|
||||
func newJSLoginApp() *jsLoginApp { return &jsLoginApp{sessions: map[string]bool{}} }
|
||||
|
||||
func (a *jsLoginApp) server() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", a.handleLogin)
|
||||
mux.HandleFunc("/dashboard", a.handleDashboard)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
const jsLoginPage = `<html><head><title>Login</title></head><body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
var f = document.createElement('form');
|
||||
f.setAttribute('action', '/login');
|
||||
f.setAttribute('method', 'post');
|
||||
f.innerHTML = '<input type="email" name="email" autocomplete="username">' +
|
||||
'<input type="password" name="password">' +
|
||||
'<button type="submit">Sign in</button>';
|
||||
document.getElementById('app').appendChild(f);
|
||||
</script></body></html>`
|
||||
|
||||
func (a *jsLoginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, jsLoginPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, jsLoginPage) // re-render: still a (JS) login page
|
||||
return
|
||||
}
|
||||
token := "sess-dave"
|
||||
a.mu.Lock()
|
||||
a.sessions[token] = true
|
||||
a.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *jsLoginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
ok := a.sessions[c.Value]
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "<html><body><h1>welcome dave</h1></body></html>")
|
||||
}
|
||||
|
||||
func TestLoginHeadless_JSRenderedForm(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
// Sanity: the HTTP engine cannot handle this page because the form only
|
||||
// exists after JS runs — the raw HTML has no <form>.
|
||||
_, httpErr := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
})
|
||||
require.ErrorIs(t, httpErr, ErrNoLoginForm, "HTTP engine should fail on a JS-rendered form")
|
||||
|
||||
// Headless engine renders the JS, fills and submits the form, and captures
|
||||
// the session.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "headless login should capture the session cookie")
|
||||
|
||||
// The captured session must authenticate against a protected route.
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// twoStepLoginPage is a username-first flow: the password field is hidden until
|
||||
// the "Next" button is clicked, mimicking Google/Microsoft-style logins.
|
||||
const twoStepLoginPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<button type="button" id="next">Next</button>
|
||||
<div id="pwwrap" style="display:none">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('next').addEventListener('click', function () {
|
||||
document.getElementById('pwwrap').style.display = 'block';
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_MultiStep(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{
|
||||
{Action: "fill", Selector: "#email", Value: "{{username}}"},
|
||||
{Action: "click", Selector: "#next"},
|
||||
{Action: "waitvisible", Selector: "#password"},
|
||||
{Action: "fill", Selector: "#password", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "multi-step login should capture the session cookie")
|
||||
}
|
||||
|
||||
// spaTokenPage is a pure-SPA login: on submit JS validates the credentials
|
||||
// client-side, stores a JWT in localStorage (no cookie at all) and swaps in a
|
||||
// logged-in view. This is the canonical "token lives in web storage" case that
|
||||
// motivates storage capture/replay.
|
||||
const spaTokenPage = `<html><head><title>SPA</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('email').value;
|
||||
var pw = document.getElementById('password').value;
|
||||
if (email === 'dave@example.com' && pw === 'p@ss') {
|
||||
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
|
||||
window.sessionStorage.setItem('csrf', 'abc123');
|
||||
document.body.innerHTML = '<h1>welcome dave</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>bad creds</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_LocalStorageToken(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, spaTokenPage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA login sets no cookie")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted from localStorage")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
|
||||
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
|
||||
}
|
||||
|
||||
func TestLoginHeadless_WrongPasswordFails(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "wrong",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
// disabledSubmitPage mimics a framework-validated form (e.g. Angular/Juice
|
||||
// Shop): the submit button starts disabled and is only enabled once both fields
|
||||
// have received input events. A naive click-immediately submitter hangs on the
|
||||
// disabled button; the engine must settle (so the field input enables the
|
||||
// button) and wait for the button to become interactable before clicking.
|
||||
const disabledSubmitPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit" disabled>Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
var email = document.getElementById('email');
|
||||
var pw = document.getElementById('password');
|
||||
var btn = document.getElementById('submit');
|
||||
function check() { btn.disabled = !(email.value && pw.value); }
|
||||
email.addEventListener('input', check);
|
||||
pw.addEventListener('input', check);
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_SubmitDisabledUntilValid(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, disabledSubmitPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, disabledSubmitPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err, "engine must wait for the submit button to become interactable")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "login should succeed once the button is enabled")
|
||||
}
|
||||
|
||||
// enterSubmitPage has no clickable submit control and submits only when Enter is
|
||||
// pressed in the password field (common in minimal SPA forms). It exercises the
|
||||
// engine's Enter-to-submit fallback when no submit button is found.
|
||||
const enterSubmitPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('password').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { document.getElementById('f').submit(); }
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_EnterSubmitFallback(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, enterSubmitPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, enterSubmitPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err, "engine must fall back to Enter-to-submit when no submit button exists")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "Enter-to-submit fallback should log in")
|
||||
}
|
||||
|
||||
func TestDetectStorageJWT(t *testing.T) {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
t.Run("preferred key wins over other JWT-shaped values", func(t *testing.T) {
|
||||
other := "eyJraWQiOiJvdGhlciJ9.eyJub3RpdCI6dHJ1ZX0.zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
got := detectStorageJWT(map[string]string{"random_blob": other, "token": jwt})
|
||||
require.Equal(t, jwt, got, "the value under a preferred key should be chosen")
|
||||
})
|
||||
|
||||
t.Run("extracts JWT embedded in JSON", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"auth": `{"access_token":"` + jwt + `","exp":123}`})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("extracts JWT wrapped in quotes", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"id_token": `"` + jwt + `"`})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("falls back to sessionStorage when localStorage has none", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"theme": "dark"}, map[string]string{"jwt": jwt})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("returns empty when no JWT present", func(t *testing.T) {
|
||||
require.Empty(t, detectStorageJWT(map[string]string{"theme": "dark", "lang": "en"}))
|
||||
require.Empty(t, detectStorageJWT(nil, nil))
|
||||
})
|
||||
|
||||
t.Run("ignores non-JWT values that merely start with eyJ", func(t *testing.T) {
|
||||
require.Empty(t, detectStorageJWT(map[string]string{"x": "eyJustabase64ishstring"}))
|
||||
})
|
||||
}
|
||||
|
||||
// spaAutoTokenPage stores a realistic JWT in localStorage under a conventional
|
||||
// key and sets no cookie, mirroring a token-in-web-storage SPA. It is used to
|
||||
// verify that the engine surfaces the token even without an explicit TokenRegex.
|
||||
const spaAutoTokenPage = `<html><head><title>SPA</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('email').value;
|
||||
var pw = document.getElementById('password').value;
|
||||
if (email === 'dave@example.com' && pw === 'p@ss') {
|
||||
window.localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
|
||||
document.body.innerHTML = '<h1>welcome dave</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>bad creds</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_AutoDetectStorageJWTWithoutRegex(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, spaAutoTokenPage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA login sets no cookie")
|
||||
require.Equal(t,
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
session.Token,
|
||||
"JWT in localStorage must be auto-detected as the session token without a TokenRegex")
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// maxBodyRead caps how much of the login/landing page we read into memory for
|
||||
// form detection and success checks.
|
||||
const maxBodyRead = 4 << 20 // 4MB
|
||||
|
||||
var (
|
||||
// ErrLoginFailed is returned when the login flow completed but produced no
|
||||
// usable session (no cookies, no token) — typically wrong credentials.
|
||||
ErrLoginFailed = errkit.New("auto-login produced no session (check credentials)")
|
||||
)
|
||||
|
||||
// Config describes a single auto-login attempt.
|
||||
type Config struct {
|
||||
// LoginURL is the page that serves the login form (required).
|
||||
LoginURL string
|
||||
// Username / Password are the credentials to submit. Username may be empty
|
||||
// for password-only forms.
|
||||
Username string
|
||||
Password string
|
||||
|
||||
// The following are optional overrides for cases where auto-detection would
|
||||
// guess wrong.
|
||||
|
||||
// UsernameField forces the form field name that receives the username.
|
||||
UsernameField string
|
||||
// PasswordField forces the form field name that receives the password.
|
||||
PasswordField string
|
||||
// ExtraFields are additional form values submitted verbatim; they override
|
||||
// any detected field of the same name (e.g. a fixed TOTP or tenant id).
|
||||
ExtraFields map[string]string
|
||||
// AllowCrossOriginAction lets the HTTP engine submit the credentials to a
|
||||
// form action on a different origin than LoginURL (e.g. an SSO provider).
|
||||
// By default such a form is refused: the login page belongs to a scan
|
||||
// target and could send the credentials anywhere.
|
||||
AllowCrossOriginAction bool
|
||||
// TokenRegex, when set, is applied to the final response body (and, for the
|
||||
// HTTP engine, response headers) to extract a bearer/session token (first
|
||||
// capture group). This enables token-based logins that return the token in
|
||||
// the response body rather than a cookie. For headless logins it is also
|
||||
// matched against window.localStorage.
|
||||
TokenRegex string
|
||||
// Timeout bounds the whole login flow. Defaults to 30s when zero.
|
||||
Timeout time.Duration
|
||||
|
||||
// The following fields apply only to the headless engine (LoginHeadless).
|
||||
|
||||
// Headless drives a real browser (go-rod) instead of the HTTP engine,
|
||||
// enabling JS-rendered / SPA / multi-step login pages.
|
||||
Headless bool
|
||||
// ShowBrowser runs the browser headful (useful for debugging).
|
||||
ShowBrowser bool
|
||||
// UseInstalledChrome forces using a system-installed Chrome binary.
|
||||
UseInstalledChrome bool
|
||||
// Proxy routes browser traffic through the given proxy URL.
|
||||
Proxy string
|
||||
// CDPEndpoint connects to an existing browser over the Chrome DevTools
|
||||
// Protocol instead of launching one.
|
||||
CDPEndpoint string
|
||||
// UserAgent overrides the browser's User-Agent for the login navigation.
|
||||
UserAgent string
|
||||
// CustomHeaders are extra headers sent with the headless login requests
|
||||
// (e.g. global -H headers from the scan).
|
||||
CustomHeaders map[string]string
|
||||
// SettleTime is how long to wait for the post-submit navigation / SPA to
|
||||
// settle before capturing the session. Defaults to 5s.
|
||||
SettleTime time.Duration
|
||||
// Steps, when non-empty, drives an explicit multi-step headless login
|
||||
// (e.g. username-first / SSO consent flows) instead of the single-shot
|
||||
// auto-detect-and-submit path. Ignored by the HTTP engine.
|
||||
Steps []LoginStep
|
||||
}
|
||||
|
||||
// LoginStep is a single action in an explicit multi-step headless login flow.
|
||||
// In Value, the placeholders {{username}} and {{password}} are substituted with
|
||||
// the configured credentials so secrets stay out of the step list.
|
||||
type LoginStep struct {
|
||||
// Action is one of: navigate, fill, click, waitvisible, wait, press, submit.
|
||||
Action string `json:"action" yaml:"action"`
|
||||
// Selector is a CSS selector (or, for fill/press, an input name) the action
|
||||
// targets. Unused by navigate/wait.
|
||||
Selector string `json:"selector" yaml:"selector"`
|
||||
// Value is the action argument: a URL (navigate), text to type (fill), a key
|
||||
// name such as "enter"/"tab" (press) or a duration like "2s" (wait).
|
||||
Value string `json:"value" yaml:"value"`
|
||||
}
|
||||
|
||||
// Session is the captured result of a successful auto-login.
|
||||
type Session struct {
|
||||
// Cookies are the session cookies captured after login (name/value only).
|
||||
Cookies []*http.Cookie
|
||||
// CookieHeader is the cookies rendered as a single "k=v; k2=v2" header value.
|
||||
CookieHeader string
|
||||
// Token is a token extracted via Config.TokenRegex, if any.
|
||||
Token string
|
||||
// LocalStorage / SessionStorage hold the browser storage captured after a
|
||||
// headless login (always empty for the HTTP engine). They let token-based
|
||||
// SPAs surface their session even when it lives in web storage.
|
||||
LocalStorage map[string]string
|
||||
SessionStorage map[string]string
|
||||
// FinalURL / FinalStatus describe where the login flow landed.
|
||||
FinalURL string
|
||||
FinalStatus int
|
||||
}
|
||||
|
||||
// Login performs a form-based auto-login: it fetches the login page, detects the
|
||||
// login form, submits the supplied credentials (carrying any hidden CSRF tokens
|
||||
// and the cookie jar), follows redirects and captures the resulting session.
|
||||
//
|
||||
// base is an optional template client whose Transport is reused (for proxy/TLS
|
||||
// settings); its cookie jar and redirect policy are intentionally NOT reused so
|
||||
// the login runs with a clean, dedicated jar. Pass nil to use defaults.
|
||||
func Login(ctx context.Context, base *http.Client, cfg Config) (*Session, error) {
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login: login-url is required")
|
||||
}
|
||||
loginURL, err := url.Parse(cfg.LoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
tokenRe, err = regexp.Compile(cfg.TokenRegex)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to create cookie jar")
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
client := &http.Client{Jar: jar, Timeout: timeout, CheckRedirect: checkLoginRedirect(cfg.AllowCrossOriginAction)}
|
||||
switch {
|
||||
case base != nil && base.Transport != nil:
|
||||
// Reuse the template client's transport (proxy/TLS) when provided.
|
||||
client.Transport = base.Transport
|
||||
case cfg.Proxy != "":
|
||||
// Otherwise honor an explicitly configured proxy so a proxied scan does
|
||||
// not bypass the proxy during HTTP auto-login.
|
||||
tr, perr := proxyTransport(cfg.Proxy)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
client.Transport = tr
|
||||
}
|
||||
|
||||
// 1. Fetch the login page (this also seeds the jar with any pre-login CSRF
|
||||
// cookie the server sets).
|
||||
pageBody, err := doRequest(ctx, client, http.MethodGet, loginURL.String(), "", "")
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to fetch login page")
|
||||
}
|
||||
|
||||
// 2. Detect the login form.
|
||||
form, err := DetectLoginForm(pageBody, loginURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actionURL, perr := url.Parse(form.Action); perr == nil && !sameOrigin(loginURL, actionURL) {
|
||||
if !cfg.AllowCrossOriginAction {
|
||||
return nil, errkit.Newf("auto-login: login form at %s submits to a different origin (%s); set allow-cross-origin-action to send the credentials there", loginURL.Redacted(), actionURL.Redacted())
|
||||
}
|
||||
gologger.Warning().Msgf("auto-login: login form at %s submits the credentials to a different origin (%s)", loginURL.Redacted(), actionURL.Redacted())
|
||||
}
|
||||
|
||||
// 3. Build the submission values: detected hidden/default fields first, then
|
||||
// the credentials, then caller overrides.
|
||||
values := url.Values{}
|
||||
for k, v := range form.Fields {
|
||||
values.Set(k, v)
|
||||
}
|
||||
userField := form.UsernameField
|
||||
if cfg.UsernameField != "" {
|
||||
userField = cfg.UsernameField
|
||||
}
|
||||
passField := form.PasswordField
|
||||
if cfg.PasswordField != "" {
|
||||
passField = cfg.PasswordField
|
||||
}
|
||||
if userField != "" {
|
||||
values.Set(userField, cfg.Username)
|
||||
}
|
||||
values.Set(passField, cfg.Password)
|
||||
for k, v := range cfg.ExtraFields {
|
||||
values.Set(k, v)
|
||||
}
|
||||
|
||||
// 4. Submit the form.
|
||||
var finalBody, finalURLStr string
|
||||
var finalStatus int
|
||||
var finalHeaders http.Header
|
||||
if form.Method == http.MethodGet {
|
||||
// A GET login places the password in the query string, where it can leak
|
||||
// into server/proxy logs and browser history. We still honor it (some
|
||||
// apps genuinely use GET), but warn so the operator is aware.
|
||||
if cfg.Password != "" {
|
||||
gologger.Warning().Msgf("auto-login: login form at %s uses GET; credentials will be sent in the URL query string", form.Action)
|
||||
}
|
||||
actionURL, perr := url.Parse(form.Action)
|
||||
if perr != nil {
|
||||
return nil, errkit.Wrap(perr, "auto-login: invalid form action")
|
||||
}
|
||||
q := actionURL.Query()
|
||||
for k, vs := range values {
|
||||
for _, v := range vs {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
actionURL.RawQuery = q.Encode()
|
||||
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, http.MethodGet, actionURL.String(), "", "")
|
||||
} else {
|
||||
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, form.Method, form.Action, "application/x-www-form-urlencoded", values.Encode())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to submit login form")
|
||||
}
|
||||
|
||||
// 5. Capture the session from the jar across the login/action/final hosts.
|
||||
session := &Session{FinalURL: finalURLStr, FinalStatus: finalStatus}
|
||||
session.Cookies = collectCookies(jar, loginURL, form.Action, finalURLStr)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
|
||||
// Token extraction: the configured regex against the body first, then the
|
||||
// response headers (regex + well-known auth headers). Many token-based APIs
|
||||
// return the session token in a header (Authorization, X-Auth-Token, ...)
|
||||
// rather than the body or a cookie.
|
||||
if tokenRe != nil {
|
||||
if m := tokenRe.FindStringSubmatch(finalBody); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
if session.Token == "" {
|
||||
session.Token = detectHeaderToken(finalHeaders, tokenRe)
|
||||
}
|
||||
|
||||
// 6. Decide success.
|
||||
//
|
||||
// Auth failure statuses with a pre-login CSRF cookie must not count as a
|
||||
// successful session — reject 401/403 before treating cookies as material.
|
||||
if finalStatus == http.StatusUnauthorized || finalStatus == http.StatusForbidden {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "final status %d at %s", finalStatus, finalURLStr)
|
||||
}
|
||||
|
||||
// "Captured a cookie" is NOT a sufficient signal: servers commonly set a
|
||||
// pre-login CSRF cookie on the GET that survives a failed POST. The strongest
|
||||
// generic failure signal is being re-prompted — i.e. the page we land on
|
||||
// after submitting still presents a login form (a password field). When that
|
||||
// happens and we did not extract a token, the login failed (e.g. wrong
|
||||
// credentials). NOTE: this can misfire on apps that render a login form on
|
||||
// every page even when authenticated; such cases can use TokenRegex or field
|
||||
// overrides to disambiguate.
|
||||
if session.Token == "" {
|
||||
var finalURLParsed *url.URL
|
||||
if u, perr := url.Parse(finalURLStr); perr == nil {
|
||||
finalURLParsed = u
|
||||
}
|
||||
if _, derr := DetectLoginForm(finalBody, finalURLParsed); derr == nil {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form (final status %d at %s)", finalStatus, finalURLStr)
|
||||
}
|
||||
}
|
||||
|
||||
// A session must ultimately carry at least one cookie or an extracted token.
|
||||
if len(session.Cookies) == 0 && session.Token == "" {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "final status %d at %s", finalStatus, finalURLStr)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// proxyTransport builds an http.Transport that routes through the given proxy.
|
||||
// It supports http/https proxies (via CONNECT) and socks5/socks5h proxies.
|
||||
func proxyTransport(rawProxy string) (*http.Transport, error) {
|
||||
u, err := url.Parse(rawProxy)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid proxy url")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
return &http.Transport{Proxy: http.ProxyURL(u)}, nil
|
||||
case "socks5", "socks5h":
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
password, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: password}
|
||||
}
|
||||
dialer, derr := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
|
||||
if derr != nil {
|
||||
return nil, errkit.Wrap(derr, "auto-login: invalid socks5 proxy")
|
||||
}
|
||||
tr := &http.Transport{}
|
||||
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
tr.DialContext = ctxDialer.DialContext
|
||||
} else {
|
||||
tr.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
return tr, nil
|
||||
default:
|
||||
return nil, errkit.Newf("auto-login: unsupported proxy scheme %q", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// collectCookies gathers cookies the jar holds for the login/action/final URLs.
|
||||
// Cookies with Domain/Path are de-duplicated by name+domain+path. Jar cookies
|
||||
// that lack scope metadata are de-duplicated by name, with later URLs winning so
|
||||
// a post-login session cookie replaces a same-name pre-login CSRF cookie.
|
||||
func collectCookies(jar http.CookieJar, loginURL *url.URL, action, final string) []*http.Cookie {
|
||||
urls := []*url.URL{loginURL}
|
||||
if u, err := url.Parse(action); err == nil {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
if u, err := url.Parse(final); err == nil {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
seen := map[string]int{}
|
||||
var out []*http.Cookie
|
||||
for _, u := range urls {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range jar.Cookies(u) {
|
||||
key := cookieDedupeKey(c)
|
||||
if idx, ok := seen[key]; ok {
|
||||
out[idx] = c
|
||||
continue
|
||||
}
|
||||
seen[key] = len(out)
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cookieDedupeKey(c *http.Cookie) string {
|
||||
if c.Domain == "" && c.Path == "" {
|
||||
return c.Name
|
||||
}
|
||||
return c.Name + "\x00" + c.Domain + "\x00" + c.Path
|
||||
}
|
||||
|
||||
// renderCookieHeader renders cookies as a single Cookie header value.
|
||||
func renderCookieHeader(cookies []*http.Cookie) string {
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, c := range cookies {
|
||||
parts = append(parts, c.Name+"="+c.Value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// checkLoginRedirect keeps the default limit of 10 redirects and refuses a
|
||||
// 307/308 redirect that would replay the submitted form body (with the
|
||||
// password) to another origin. Without it a login endpoint could bounce the
|
||||
// credentials anywhere, which the form action origin check alone cannot stop.
|
||||
func checkLoginRedirect(allowCrossOrigin bool) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errkit.New("auto-login: stopped after 10 redirects")
|
||||
}
|
||||
if allowCrossOrigin || req.Method == http.MethodGet || req.Method == http.MethodHead {
|
||||
return nil
|
||||
}
|
||||
if !sameOrigin(via[len(via)-1].URL, req.URL) {
|
||||
return errkit.Newf("auto-login: refusing to replay the login form to a different origin (%s); set allow-cross-origin-action to send the credentials there", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sameOrigin reports whether a and b share scheme, host and port, with the
|
||||
// default port of the scheme applied when one is missing.
|
||||
func sameOrigin(a, b *url.URL) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(a.Scheme, b.Scheme) &&
|
||||
strings.EqualFold(a.Hostname(), b.Hostname()) &&
|
||||
originPort(a) == originPort(b)
|
||||
}
|
||||
|
||||
func originPort(u *url.URL) string {
|
||||
if port := u.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return "443"
|
||||
case "http":
|
||||
return "80"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// doRequest issues a request and returns only the body (used for the GET page).
|
||||
func doRequest(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, error) {
|
||||
b, _, _, _, err := doRequestFull(ctx, client, method, rawURL, contentType, body)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// doRequestFull issues a request and returns the (capped) body, the final URL
|
||||
// after redirects, the final status code and the final response headers.
|
||||
func doRequestFull(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, string, int, http.Header, error) {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, reader)
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyRead))
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
finalURL := rawURL
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
return string(data), finalURL, resp.StatusCode, resp.Header, nil
|
||||
}
|
||||
|
||||
// tokenHeaderNames are response headers commonly used to deliver a session /
|
||||
// bearer token, checked in priority order.
|
||||
var tokenHeaderNames = []string{
|
||||
"Authorization",
|
||||
"X-Auth-Token",
|
||||
"X-Access-Token",
|
||||
"Access-Token",
|
||||
"X-Subject-Token",
|
||||
"X-Session-Token",
|
||||
"X-Api-Token",
|
||||
}
|
||||
|
||||
// detectHeaderToken extracts a session/bearer token from response headers. A
|
||||
// configured token-regex is tried first (so a custom header can be targeted),
|
||||
// then the well-known token headers in priority order. A leading "Bearer "
|
||||
// scheme is stripped from the value.
|
||||
func detectHeaderToken(h http.Header, tokenRe *regexp.Regexp) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
if tokenRe != nil {
|
||||
names := make([]string, 0, len(h))
|
||||
for name := range h {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
for _, v := range h[name] {
|
||||
if m := tokenRe.FindStringSubmatch(v); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range tokenHeaderNames {
|
||||
v := strings.TrimSpace(h.Get(name))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Strip a leading auth scheme (e.g. "Bearer <token>").
|
||||
if i := strings.IndexByte(v, ' '); i > 0 && strings.EqualFold(v[:i], "bearer") {
|
||||
v = strings.TrimSpace(v[i+1:])
|
||||
}
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loginApp is a hermetic web app with a realistic, CSRF-protected, cookie-based
|
||||
// login flow used to exercise the auto-login engine end to end:
|
||||
// - GET /login sets a per-visitor csrf cookie and renders a form echoing it in
|
||||
// a hidden input.
|
||||
// - POST /login validates the csrf (hidden field must equal the cookie),
|
||||
// checks the credentials, and on success sets a `session` cookie and 302s to
|
||||
// /dashboard.
|
||||
// - GET /dashboard returns 200 only when a valid session cookie is presented.
|
||||
type loginApp struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]bool // valid session tokens
|
||||
user string
|
||||
pass string
|
||||
}
|
||||
|
||||
func newLoginApp(user, pass string) *loginApp {
|
||||
return &loginApp{sessions: map[string]bool{}, user: user, pass: pass}
|
||||
}
|
||||
|
||||
func (a *loginApp) server() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", a.handleLogin)
|
||||
mux.HandleFunc("/dashboard", a.handleDashboard)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func (a *loginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
// Seed a CSRF cookie and echo it into the form.
|
||||
csrf := "csrf-" + fmt.Sprint(len(a.sessions)) + "-tok"
|
||||
http.SetCookie(w, &http.Cookie{Name: "csrftoken", Value: csrf, Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="csrf" value="%s">
|
||||
<input type="text" name="email" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" name="commit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`, csrf)
|
||||
return
|
||||
}
|
||||
|
||||
// POST: validate CSRF against the cookie, then credentials.
|
||||
_ = r.ParseForm()
|
||||
csrfCookie, err := r.Cookie("csrftoken")
|
||||
if err != nil || r.PostFormValue("csrf") != csrfCookie.Value {
|
||||
http.Error(w, "csrf mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("email") != a.user || r.PostFormValue("password") != a.pass {
|
||||
// Re-render the login form on failure (no session set).
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, `<html><body><p>Invalid credentials</p>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="email">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`)
|
||||
return
|
||||
}
|
||||
token := "sess-" + r.PostFormValue("email")
|
||||
a.mu.Lock()
|
||||
a.sessions[token] = true
|
||||
a.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *loginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
ok := a.sessions[c.Value]
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "welcome to your dashboard")
|
||||
}
|
||||
|
||||
func TestLogin_CSRFFormAndSessionCookie(t *testing.T) {
|
||||
app := newLoginApp("[email protected]", "s3cr3t")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "s3cr3t",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies, "should capture session cookie")
|
||||
|
||||
// The captured session must actually authenticate against a protected route.
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "session cookie must be captured")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "captured session should grant access to /dashboard")
|
||||
}
|
||||
|
||||
func TestLogin_WrongPasswordFails(t *testing.T) {
|
||||
app := newLoginApp("[email protected]", "s3cr3t")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "wrong-password",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
func TestLogin_FieldOverride(t *testing.T) {
|
||||
// Force the username field name via override; detection would pick "email"
|
||||
// anyway, but this verifies the override path submits correctly.
|
||||
app := newLoginApp("[email protected]", "hunter2")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "hunter2",
|
||||
UsernameField: "email",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
}
|
||||
|
||||
// tokenApp returns a JSON token in the login response body instead of setting a
|
||||
// cookie, to exercise TokenRegex-based capture.
|
||||
func tokenApp() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/api/login" method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="password">
|
||||
</form>`)
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == "carol" && r.PostFormValue("password") == "pw" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"access_token":"eyJhbGciOiJI.payload.sig","expires_in":3600}`)
|
||||
return
|
||||
}
|
||||
http.Error(w, "bad creds", http.StatusUnauthorized)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_TokenExtraction(t *testing.T) {
|
||||
srv := tokenApp()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "carol",
|
||||
Password: "pw",
|
||||
TokenRegex: `"access_token":"([^"]+)"`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token)
|
||||
}
|
||||
|
||||
// getFormApp serves a GET-method login form (credentials travel in the query
|
||||
// string). It exercises the GET submission branch of Login.
|
||||
func getFormApp(user, pass string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
// A submit is any GET carrying the password param.
|
||||
if q.Get("password") != "" {
|
||||
if q.Get("user") == user && q.Get("password") == pass && q.Get("realm") == "corp" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body>welcome, no form here</body></html>`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
|
||||
<input type="text" name="user"><input type="password" name="password"></form></body></html>`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
|
||||
<input type="hidden" name="realm" value="corp">
|
||||
<input type="text" name="user" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_GetMethodForm(t *testing.T) {
|
||||
srv := getFormApp("dave", "pw123")
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "dave",
|
||||
Password: "pw123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies, "GET-form login should still capture the session cookie")
|
||||
// The hidden 'realm' default must have been carried into the query string.
|
||||
require.Contains(t, session.FinalURL, "realm=corp")
|
||||
}
|
||||
|
||||
// extraFieldApp requires an additional non-form field (e.g. a tenant id) that is
|
||||
// not present in the markup, so it must be supplied via Config.ExtraFields.
|
||||
func extraFieldApp(user, pass string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
|
||||
<input type="text" name="user"><input type="password" name="password"></form>`)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == user && r.PostFormValue("password") == pass && r.PostFormValue("tenant") == "acme" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_ExtraFields(t *testing.T) {
|
||||
srv := extraFieldApp("erin", "pw")
|
||||
defer srv.Close()
|
||||
|
||||
// Without the extra field the login must fail (re-prompted).
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login", Username: "erin", Password: "pw",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
|
||||
// Supplying the tenant via ExtraFields must succeed.
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "erin",
|
||||
Password: "pw",
|
||||
ExtraFields: map[string]string{"tenant": "acme"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
}
|
||||
|
||||
func TestProxyTransport(t *testing.T) {
|
||||
tr, err := proxyTransport("http://127.0.0.1:8080")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tr.Proxy, "http proxy must set Transport.Proxy")
|
||||
|
||||
tr, err = proxyTransport("socks5://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tr.DialContext, "socks5 proxy must set Transport.DialContext")
|
||||
|
||||
_, err = proxyTransport("ftp://127.0.0.1:21")
|
||||
require.Error(t, err, "unsupported proxy scheme must error")
|
||||
}
|
||||
|
||||
// TestLogin_HTTPProxy proves the HTTP engine actually routes through cfg.Proxy:
|
||||
// the login target host is unresolvable, so the flow can only succeed if every
|
||||
// request is sent to the proxy (which serves the login app by path).
|
||||
func TestLogin_HTTPProxy(t *testing.T) {
|
||||
var proxyHits int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&proxyHits, 1)
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
|
||||
<input type="text" name="user"><input type="password" name="password"></form>`)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == "frank" && r.PostFormValue("password") == "pw" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
|
||||
})
|
||||
proxySrv := httptest.NewServer(mux)
|
||||
defer proxySrv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
// Unresolvable host: only reachable if the proxy is honored.
|
||||
LoginURL: "http://login.invalid.test/login",
|
||||
Username: "frank",
|
||||
Password: "pw",
|
||||
Proxy: proxySrv.URL,
|
||||
})
|
||||
require.NoError(t, err, "login should succeed via the proxy")
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
require.GreaterOrEqual(t, atomic.LoadInt32(&proxyHits), int32(2), "both GET and POST must traverse the proxy")
|
||||
}
|
||||
|
||||
func TestLogin_NoFormIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "<html><body>no form here</body></html>")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "x",
|
||||
Password: "y",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectHeaderToken(t *testing.T) {
|
||||
t.Run("strips bearer scheme from Authorization", func(t *testing.T) {
|
||||
h := http.Header{"Authorization": {"Bearer abc.def.ghi"}}
|
||||
require.Equal(t, "abc.def.ghi", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("reads well-known token header", func(t *testing.T) {
|
||||
h := http.Header{"X-Auth-Token": {"tok-123"}}
|
||||
require.Equal(t, "tok-123", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("priority order prefers Authorization over X-Auth-Token", func(t *testing.T) {
|
||||
h := http.Header{"Authorization": {"Bearer first"}, "X-Auth-Token": {"second"}}
|
||||
require.Equal(t, "first", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("token-regex targets a custom header", func(t *testing.T) {
|
||||
h := http.Header{"X-Custom": {"session=zzz999;"}}
|
||||
re := regexp.MustCompile(`session=([^;]+)`)
|
||||
require.Equal(t, "zzz999", detectHeaderToken(h, re))
|
||||
})
|
||||
t.Run("no token headers yields empty", func(t *testing.T) {
|
||||
require.Empty(t, detectHeaderToken(http.Header{"Content-Type": {"text/html"}}, nil))
|
||||
require.Empty(t, detectHeaderToken(nil, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogin_CrossOriginActionRefused(t *testing.T) {
|
||||
var collected atomic.Int32
|
||||
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
collected.Add(1)
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "stolen", Path: "/"})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer collector.Close()
|
||||
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body>
|
||||
<form action="%s/collect" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`, collector.URL)
|
||||
}))
|
||||
defer page.Close()
|
||||
|
||||
cfg := Config{LoginURL: page.URL + "/login", Username: "admin", Password: "s3cret"}
|
||||
_, err := Login(context.Background(), nil, cfg)
|
||||
require.ErrorContains(t, err, "different origin")
|
||||
require.Zero(t, collected.Load(), "credentials must not be sent to another origin by default")
|
||||
|
||||
cfg.AllowCrossOriginAction = true
|
||||
session, err := Login(context.Background(), nil, cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
require.Equal(t, int32(1), collected.Load())
|
||||
}
|
||||
|
||||
// A same-origin form action that answers with a 307/308 would make the client
|
||||
// replay the form body, password included, to the redirect target.
|
||||
func TestLogin_CrossOriginRedirectReplayRefused(t *testing.T) {
|
||||
var bodies atomic.Int32
|
||||
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
bodies.Add(1)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "stolen", Path: "/"})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer collector.Close()
|
||||
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
http.Redirect(w, r, collector.URL+"/collect", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`)
|
||||
}))
|
||||
defer page.Close()
|
||||
|
||||
cfg := Config{LoginURL: page.URL + "/login", Username: "admin", Password: "s3cret"}
|
||||
_, err := Login(context.Background(), nil, cfg)
|
||||
require.ErrorContains(t, err, "different origin")
|
||||
require.Zero(t, bodies.Load(), "the password must not be replayed to another origin")
|
||||
|
||||
cfg.AllowCrossOriginAction = true
|
||||
_, err = Login(context.Background(), nil, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), bodies.Load())
|
||||
}
|
||||
|
||||
func TestSameOrigin(t *testing.T) {
|
||||
parse := func(raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
require.True(t, sameOrigin(parse("https://Example.com/login"), parse("https://example.com:443/session")))
|
||||
require.True(t, sameOrigin(parse("http://example.com:8080/a"), parse("http://example.com:8080/b")))
|
||||
require.False(t, sameOrigin(parse("https://example.com/login"), parse("http://example.com/session")))
|
||||
require.False(t, sameOrigin(parse("https://example.com/login"), parse("https://evil.example/collect")))
|
||||
require.False(t, sameOrigin(parse("http://example.com/login"), parse("http://example.com:8080/session")))
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/fuzzplayground"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// playgroundServer starts the in-repo auth playground and returns its base URL.
|
||||
func playgroundServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(fuzzplayground.GetPlaygroundServer())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// assertSessionAuthenticates verifies a captured session actually authenticates
|
||||
// against the playground's protected /auth/whoami endpoint, trying both the
|
||||
// cookie and bearer-token representations.
|
||||
func assertSessionAuthenticates(t *testing.T, base string, s *Session) {
|
||||
t.Helper()
|
||||
apply := func(req *http.Request) {
|
||||
if s.CookieHeader != "" {
|
||||
req.Header.Set("Cookie", s.CookieHeader)
|
||||
}
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/auth/whoami", nil)
|
||||
apply(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "whoami should accept the captured session; body=%s", body)
|
||||
require.Contains(t, string(body), fuzzplayground.AuthUsername, "whoami should identify the logged-in user")
|
||||
}
|
||||
|
||||
func cookieNames(s *Session) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, c := range s.Cookies {
|
||||
out[c.Name] = c.Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- HTTP engine styles ----------------------------------------------------
|
||||
|
||||
func TestPlayground_HTTPFormLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION", "form login should set a session cookie")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HTTPCSRFLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/csrf-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "HTTP engine must capture and replay the hidden CSRF token")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HTTPWrongPasswordFails(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: "wrong",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
// TestPlayground_HTTPHeaderTokenLogin proves the HTTP engine extracts a session
|
||||
// token delivered in a response header (no cookie, no body token).
|
||||
func TestPlayground_HTTPHeaderTokenLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/header-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "the engine must extract the token from the response header")
|
||||
require.Empty(t, session.Cookies, "header-token login sets no cookie")
|
||||
require.NotEmpty(t, session.Token, "token must be read from the X-Auth-Token header")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// --- Headless styles -------------------------------------------------------
|
||||
|
||||
func TestPlayground_HeadlessSPALogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// The static HTTP engine cannot log in here (the form is built by JS).
|
||||
_, httpErr := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/spa-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.ErrorIs(t, httpErr, ErrNoLoginForm)
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessMultiStep(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/multistep-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{
|
||||
{Action: "fill", Selector: "#username", Value: "{{username}}"},
|
||||
{Action: "click", Selector: "#next"},
|
||||
{Action: "waitvisible", Selector: "#password"},
|
||||
{Action: "fill", Selector: "#password", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessStrictSubmit is the regression for the disabled-until-
|
||||
// valid submit button: the engine must settle (so JS enables the button) and/or
|
||||
// fall back to Enter rather than hang on the disabled control.
|
||||
func TestPlayground_HeadlessStrictSubmit(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/strict-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessSPAToken(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)`,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA-token login sets no cookie")
|
||||
require.NotEmpty(t, session.Token, "token must be extracted from localStorage")
|
||||
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
|
||||
// The captured bearer token must authenticate the protected API.
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessSPATokenAutoDetect proves the engine surfaces a JWT
|
||||
// kept in web storage as the session token even when no token-regex is given
|
||||
// (the JWT is stored under the conventional "access_token" key).
|
||||
func TestPlayground_HeadlessSPATokenAutoDetect(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
// Intentionally no TokenRegex: rely on web-storage JWT auto-detection.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Token, "JWT in localStorage must be auto-detected without a token-regex")
|
||||
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HTTPMultiFormPicksLogin proves the form detector selects the
|
||||
// credential form on a page that also contains a decoy search form (no
|
||||
// password), rather than blindly taking the first <form>.
|
||||
func TestPlayground_HTTPMultiFormPicksLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/multiform-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "detector must pick the login form over the decoy search form")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessJSCookieLogin proves cookies set client-side via
|
||||
// document.cookie (no Set-Cookie header) are captured from the browser jar.
|
||||
func TestPlayground_HeadlessJSCookieLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/jscookie-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION", "JS-set cookie must be captured from the browser")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessDelayedFormLogin proves the engine waits for a login
|
||||
// form that is injected asynchronously instead of failing immediately.
|
||||
func TestPlayground_HeadlessDelayedFormLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/delayed-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 2 * time.Second,
|
||||
})
|
||||
require.NoError(t, err, "engine must wait for the asynchronously-rendered form")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessHeaderTokenLogin proves the headless engine recovers a
|
||||
// token that is only ever delivered in an XHR response header (never stored in
|
||||
// a cookie or web storage) via passive response-header interception.
|
||||
func TestPlayground_HeadlessHeaderTokenLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-header-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "header-token SPA sets no cookie")
|
||||
require.Empty(t, session.LocalStorage["token"], "the SPA does not store the token")
|
||||
require.NotEmpty(t, session.Token, "token must be captured from the XHR response header")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessOAuthRedirect(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Pure redirect SSO: visiting authorize logs the user in; there is no form,
|
||||
// so a single settle step lets the redirect chain complete before capture.
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/oauth/authorize",
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{{Action: "wait", Value: "1s"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_RecordingReplay imports a Chrome Recorder export (with the base
|
||||
// URL templated in), verifies the recorded credential literals are parameterized
|
||||
// out, and replays the compiled steps headlessly to obtain a real session.
|
||||
func TestPlayground_RecordingReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "recordings", "spa_login.json"))
|
||||
require.NoError(t, err)
|
||||
rec := strings.ReplaceAll(string(raw), "__BASE__", base)
|
||||
recFile := filepath.Join(t.TempDir(), "spa_login.json")
|
||||
require.NoError(t, os.WriteFile(recFile, []byte(rec), 0o600))
|
||||
|
||||
steps, err := StepsFromRecordingFile(recFile, fuzzplayground.AuthUsername, fuzzplayground.AuthPassword)
|
||||
require.NoError(t, err)
|
||||
// Credentials must never survive in the compiled steps.
|
||||
for _, s := range steps {
|
||||
require.NotContains(t, s.Value, fuzzplayground.AuthPassword, "password literal leaked into steps")
|
||||
require.NotContains(t, s.Value, fuzzplayground.AuthUsername, "username literal leaked into steps")
|
||||
}
|
||||
require.Contains(t, stepValues(steps), "{{username}}")
|
||||
require.Contains(t, stepValues(steps), "{{password}}")
|
||||
// An xpath-only selector must compile to the engine's xpath= form.
|
||||
require.Contains(t, stepSelectors(steps), "xpath=//input[@id='email']")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: FirstNavigateURL(steps),
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
Steps: steps,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func stepValues(steps []LoginStep) []string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stepSelectors(steps []LoginStep) []string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Selector)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Authenticated fuzzing target -----------------------------------------
|
||||
|
||||
// TestPlayground_AuthenticatedEndpointBehindLogin proves the authenticated,
|
||||
// fuzzable endpoint is only reachable with a captured session and reflects input
|
||||
// (a genuine target behind the login wall for authenticated scanning).
|
||||
func TestPlayground_AuthenticatedEndpointBehindLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unauthenticated: blocked.
|
||||
resp, err := http.Get(base + "/auth/api/search?q=hello")
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "search must require auth")
|
||||
|
||||
// Authenticated: reachable and reflects the payload unsanitized.
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/auth/api/search?q="+payloadXSS, nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
require.True(t, strings.Contains(string(body), payloadXSS), "authenticated endpoint should reflect the payload")
|
||||
}
|
||||
|
||||
const payloadXSS = "<script>alert(1)</script>"
|
||||
@@ -0,0 +1,138 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Real-application harness. These tests are skipped unless NUCLEI_REALAPP_LOGIN_URL
|
||||
// is set, so normal CI is unaffected. They drive the headless engine against a
|
||||
// live app to flush out engine bugs that synthetic httptest servers miss
|
||||
// (heavy SPA bootstrap, XHR logins, hash routes, token-in-web-storage, etc.).
|
||||
//
|
||||
// Example (OWASP Juice Shop):
|
||||
//
|
||||
// docker run -d -p 3000:3000 bkimminich/juice-shop
|
||||
// NUCLEI_REALAPP_LOGIN_URL='http://localhost:3000/#/login' \
|
||||
// NUCLEI_REALAPP_USERNAME='[email protected]' \
|
||||
// NUCLEI_REALAPP_PASSWORD='admin123' \
|
||||
// NUCLEI_REALAPP_TOKEN_REGEX='(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)' \
|
||||
// NUCLEI_REALAPP_EXPECT_STORAGE_KEY='token' \
|
||||
// go test ./pkg/authprovider/autologin/ -run TestRealApp_HeadlessLogin -v
|
||||
func realAppConfig(t *testing.T) (Config, bool) {
|
||||
t.Helper()
|
||||
loginURL := os.Getenv("NUCLEI_REALAPP_LOGIN_URL")
|
||||
if loginURL == "" {
|
||||
t.Skip("set NUCLEI_REALAPP_LOGIN_URL to run the real-app login harness")
|
||||
return Config{}, false
|
||||
}
|
||||
cfg := Config{
|
||||
LoginURL: loginURL,
|
||||
Username: os.Getenv("NUCLEI_REALAPP_USERNAME"),
|
||||
Password: os.Getenv("NUCLEI_REALAPP_PASSWORD"),
|
||||
UsernameField: os.Getenv("NUCLEI_REALAPP_USERNAME_FIELD"),
|
||||
PasswordField: os.Getenv("NUCLEI_REALAPP_PASSWORD_FIELD"),
|
||||
TokenRegex: os.Getenv("NUCLEI_REALAPP_TOKEN_REGEX"),
|
||||
Proxy: os.Getenv("NUCLEI_REALAPP_PROXY"),
|
||||
SettleTime: 8 * time.Second,
|
||||
Timeout: 90 * time.Second,
|
||||
}
|
||||
if steps := os.Getenv("NUCLEI_REALAPP_STEPS"); steps != "" {
|
||||
require.NoError(t, json.Unmarshal([]byte(steps), &cfg.Steps), "NUCLEI_REALAPP_STEPS must be a JSON array of LoginStep")
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
func logSession(t *testing.T, s *Session) {
|
||||
t.Helper()
|
||||
t.Logf("final url: %s", s.FinalURL)
|
||||
t.Logf("cookies (%d):", len(s.Cookies))
|
||||
for _, c := range s.Cookies {
|
||||
t.Logf(" %s", c.Name)
|
||||
}
|
||||
if s.Token != "" {
|
||||
t.Logf("token: present (len=%d)", len(s.Token))
|
||||
}
|
||||
t.Logf("localStorage keys: %d, sessionStorage keys: %d", len(s.LocalStorage), len(s.SessionStorage))
|
||||
for k := range s.LocalStorage {
|
||||
t.Logf(" local[%s]", k)
|
||||
}
|
||||
for k := range s.SessionStorage {
|
||||
t.Logf(" session[%s]", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealApp_HeadlessLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
cfg, ok := realAppConfig(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, cfg)
|
||||
require.NoError(t, err, "headless login against real app failed")
|
||||
logSession(t, session)
|
||||
|
||||
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0,
|
||||
"expected to capture a session (cookie, token or web storage)")
|
||||
|
||||
if key := os.Getenv("NUCLEI_REALAPP_EXPECT_STORAGE_KEY"); key != "" {
|
||||
_, inLocal := session.LocalStorage[key]
|
||||
_, inSession := session.SessionStorage[key]
|
||||
require.True(t, inLocal || inSession, "expected web storage key %q to be captured", key)
|
||||
}
|
||||
if name := os.Getenv("NUCLEI_REALAPP_EXPECT_COOKIE"); name != "" {
|
||||
found := false
|
||||
for _, c := range session.Cookies {
|
||||
if c.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected cookie %q to be captured", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealApp_RecordingReplay drives a recorder-JSON login flow (compiled to
|
||||
// steps, with credential parameterization) against the live app, exercising the
|
||||
// full record->replay path end to end. Set NUCLEI_REALAPP_RECORDING to a Chrome
|
||||
// DevTools Recorder export to run it.
|
||||
func TestRealApp_RecordingReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
cfg, ok := realAppConfig(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
recording := os.Getenv("NUCLEI_REALAPP_RECORDING")
|
||||
if recording == "" {
|
||||
t.Skip("set NUCLEI_REALAPP_RECORDING to a recorder json to run the replay harness")
|
||||
}
|
||||
|
||||
steps, err := StepsFromRecordingFile(recording, cfg.Username, cfg.Password)
|
||||
require.NoError(t, err, "failed to compile recording")
|
||||
t.Logf("compiled %d steps from recording", len(steps))
|
||||
for _, s := range steps {
|
||||
t.Logf(" %s selector=%q", s.Action, s.Selector)
|
||||
}
|
||||
cfg.Steps = steps
|
||||
if cfg.LoginURL == "" {
|
||||
cfg.LoginURL = FirstNavigateURL(steps)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, cfg)
|
||||
require.NoError(t, err, "recorded login replay against real app failed")
|
||||
logSession(t, session)
|
||||
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0,
|
||||
"expected recorded login to capture a session")
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// recordingFile is the Chrome DevTools Recorder / @puppeteer/replay export
|
||||
// schema (the two are identical). A user records their real login in Chrome
|
||||
// (DevTools -> Recorder) and exports it as JSON; we compile it down to the same
|
||||
// LoginStep list the headless engine already replays, so complex SSO/MFA logins
|
||||
// can be authored by recording instead of hand-writing steps or templates.
|
||||
type recordingFile struct {
|
||||
Title string `json:"title"`
|
||||
Steps []recordingStep `json:"steps"`
|
||||
}
|
||||
|
||||
// recordingStep is a single recorded user action. Selectors is a list of
|
||||
// alternative selector strategies for the target element, each itself a list
|
||||
// (Chrome groups shadow/frame piercing selectors); we use the first entry of
|
||||
// each group as a candidate.
|
||||
type recordingStep struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Selectors [][]string `json:"selectors"`
|
||||
Value string `json:"value"`
|
||||
Key string `json:"key"`
|
||||
Target string `json:"target"`
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
// StepsFromRecordingFile reads and converts a recorder export file into
|
||||
// LoginSteps. username/password are used to parameterize the recording so the
|
||||
// captured credential literals are replaced with {{username}}/{{password}}
|
||||
// placeholders (kept out of the committed flow file).
|
||||
func StepsFromRecordingFile(path, username, password string) ([]LoginStep, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to read recording file")
|
||||
}
|
||||
return StepsFromRecording(data, username, password)
|
||||
}
|
||||
|
||||
// StepsFromRecording converts a recorder export (JSON) into LoginSteps.
|
||||
func StepsFromRecording(data []byte, username, password string) ([]LoginStep, error) {
|
||||
var rec recordingFile
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid recording json")
|
||||
}
|
||||
if len(rec.Steps) == 0 {
|
||||
return nil, errkit.New("auto-login: recording contains no steps")
|
||||
}
|
||||
|
||||
var steps []LoginStep
|
||||
for _, rs := range rec.Steps {
|
||||
switch strings.ToLower(strings.TrimSpace(rs.Type)) {
|
||||
case "navigate":
|
||||
if rs.URL != "" {
|
||||
steps = append(steps, LoginStep{Action: "navigate", Value: rs.URL})
|
||||
}
|
||||
case "click":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{Action: "click", Selector: sel})
|
||||
case "change":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{
|
||||
Action: "fill",
|
||||
Selector: sel,
|
||||
Value: parameterizeValue(rs.Value, rs.Selectors, username, password),
|
||||
})
|
||||
case "keydown":
|
||||
// Only emit actionable keys; character keys are captured by `change`,
|
||||
// and the paired keyUp event is ignored to avoid double submission.
|
||||
if key := normalizeKey(rs.Key); key != "" {
|
||||
steps = append(steps, LoginStep{Action: "press", Value: key, Selector: pickSelector(rs.Selectors)})
|
||||
}
|
||||
case "waitforelement":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{Action: "waitvisible", Selector: sel})
|
||||
case "setviewport", "keyup", "scroll", "close", "emulatenetworkconditions", "hover", "":
|
||||
// Not relevant to (or not supported for) headless login replay.
|
||||
continue
|
||||
default:
|
||||
return nil, errkit.Newf("auto-login: unsupported recording action %q", rs.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return nil, errkit.New("auto-login: recording produced no replayable steps")
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// FirstNavigateURL returns the URL of the first navigate step in a recording, or
|
||||
// "" if none. It lets the caller derive the login URL from the recording.
|
||||
func FirstNavigateURL(steps []LoginStep) string {
|
||||
for _, s := range steps {
|
||||
if strings.EqualFold(s.Action, "navigate") && s.Value != "" {
|
||||
return s.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeKey maps recorder key names to the engine's supported press keys.
|
||||
// Character keys (length 1) return "" because the text they produce is already
|
||||
// captured by `change` steps.
|
||||
func normalizeKey(key string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "enter", "return", "numpadenter":
|
||||
return "enter"
|
||||
case "tab":
|
||||
return "tab"
|
||||
case "escape", "esc":
|
||||
return "escape"
|
||||
case "space":
|
||||
return "space"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parameterizeValue replaces a recorded credential literal with a placeholder so
|
||||
// secrets never live in the recording file. It masks by exact match against the
|
||||
// configured credentials and, defensively, masks any value typed into a field
|
||||
// whose selector (any alternative) looks like a password input.
|
||||
func parameterizeValue(value string, selectors [][]string, username, password string) string {
|
||||
if password != "" && value == password {
|
||||
return "{{password}}"
|
||||
}
|
||||
if username != "" && value == username {
|
||||
return "{{username}}"
|
||||
}
|
||||
for _, group := range selectors {
|
||||
for _, selector := range group {
|
||||
if looksLikePasswordSelector(selector) {
|
||||
return "{{password}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func looksLikePasswordSelector(selector string) bool {
|
||||
s := strings.ToLower(selector)
|
||||
return strings.Contains(s, "password") || strings.Contains(s, "passwd") || strings.Contains(s, "type=\"password\"") || strings.Contains(s, "type=password")
|
||||
}
|
||||
|
||||
// pickSelector chooses the most engine-friendly selector from the recorder's
|
||||
// alternatives, preferring native CSS, then XPath, then an aria-label CSS
|
||||
// approximation, then a text-content XPath. Shadow-piercing (pierce/) selectors
|
||||
// are skipped because the engine cannot resolve them.
|
||||
func pickSelector(groups [][]string) string {
|
||||
var css, xpath, aria, text string
|
||||
for _, group := range groups {
|
||||
for _, s := range group {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case s == "":
|
||||
continue
|
||||
case strings.HasPrefix(s, "xpath/"):
|
||||
if xpath == "" {
|
||||
xpath = "xpath=" + strings.TrimPrefix(s, "xpath/")
|
||||
}
|
||||
case strings.HasPrefix(s, "aria/"):
|
||||
if aria == "" {
|
||||
aria = ariaToCSS(strings.TrimPrefix(s, "aria/"))
|
||||
}
|
||||
case strings.HasPrefix(s, "text/"):
|
||||
if text == "" {
|
||||
text = textToXPath(strings.TrimPrefix(s, "text/"))
|
||||
}
|
||||
case strings.HasPrefix(s, "pierce/"):
|
||||
continue
|
||||
default:
|
||||
if css == "" {
|
||||
css = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{css, xpath, aria, text} {
|
||||
if candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ariaToCSS approximates an accessibility-name selector as an aria-label CSS
|
||||
// attribute selector. It is best-effort; recordings usually also carry a CSS or
|
||||
// XPath selector that is preferred over this.
|
||||
func ariaToCSS(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.Contains(name, `"`) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`[aria-label="%s"]`, name)
|
||||
}
|
||||
|
||||
// textToXPath converts a text-content selector into an XPath the engine can run.
|
||||
func textToXPath(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" || strings.Contains(t, `"`) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`xpath=//*[contains(normalize-space(.), "%s")]`, t)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// chromeRecording is a representative Chrome DevTools Recorder export of a
|
||||
// username-first login, including the noise (setViewport, paired keyUp) and the
|
||||
// multiple selector strategies Chrome emits per step.
|
||||
const chromeRecording = `{
|
||||
"title": "login",
|
||||
"steps": [
|
||||
{"type": "setViewport", "width": 1280, "height": 720},
|
||||
{"type": "navigate", "url": "https://app.example.com/login"},
|
||||
{"type": "change", "value": "[email protected]", "selectors": [["#email"], ["aria/Email"], ["xpath///*[@id=\"email\"]"]]},
|
||||
{"type": "click", "selectors": [["#next"], ["aria/Next"]]},
|
||||
{"type": "waitForElement", "selectors": [["#password"]]},
|
||||
{"type": "change", "value": "p@ss", "selectors": [["#password"]]},
|
||||
{"type": "keyDown", "key": "Enter"},
|
||||
{"type": "keyUp", "key": "Enter"},
|
||||
{"type": "click", "selectors": [["#submit"]]}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestStepsFromRecording_MapsAndParameterizes(t *testing.T) {
|
||||
steps, err := StepsFromRecording([]byte(chromeRecording), "[email protected]", "p@ss")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, []LoginStep{
|
||||
{Action: "navigate", Value: "https://app.example.com/login"},
|
||||
{Action: "fill", Selector: "#email", Value: "{{username}}"},
|
||||
{Action: "click", Selector: "#next"},
|
||||
{Action: "waitvisible", Selector: "#password"},
|
||||
{Action: "fill", Selector: "#password", Value: "{{password}}"},
|
||||
{Action: "press", Value: "enter"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
}, steps, "recording should map to placeholder-parameterized steps (setViewport/keyUp dropped)")
|
||||
}
|
||||
|
||||
func TestStepsFromRecording_PasswordSelectorMaskedWithoutCredMatch(t *testing.T) {
|
||||
// Even if the recorded literal doesn't match configured creds, a value typed
|
||||
// into a password-looking field must never survive in the steps.
|
||||
rec := `{"steps": [
|
||||
{"type": "change", "value": "literal-secret", "selectors": [["input[type=password]"]]}
|
||||
]}`
|
||||
steps, err := StepsFromRecording([]byte(rec), "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, steps, 1)
|
||||
require.Equal(t, "{{password}}", steps[0].Value, "value typed into a password field must be masked")
|
||||
|
||||
// CSS can win over an aria/Password alternative; still mask via any selector.
|
||||
rec = `{"steps": [
|
||||
{"type": "change", "value": "literal-secret", "selectors": [["#input-2"], ["aria/Password"]]}
|
||||
]}`
|
||||
steps, err = StepsFromRecording([]byte(rec), "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, steps, 1)
|
||||
require.Equal(t, "#input-2", steps[0].Selector)
|
||||
require.Equal(t, "{{password}}", steps[0].Value, "password-looking alternate selectors must still mask")
|
||||
}
|
||||
|
||||
func TestStepsFromRecording_SelectorPriority(t *testing.T) {
|
||||
// CSS preferred over aria/xpath; xpath/ prefix becomes an engine xpath= form;
|
||||
// aria-only falls back to an aria-label CSS approximation; pierce/ is ignored.
|
||||
rec := `{"steps": [
|
||||
{"type": "click", "selectors": [["aria/Save"], ["xpath///button[1]"], ["#real"]]},
|
||||
{"type": "click", "selectors": [["pierce/#shadow"], ["xpath///div[2]"]]},
|
||||
{"type": "click", "selectors": [["aria/Submit"]]}
|
||||
]}`
|
||||
steps, err := StepsFromRecording([]byte(rec), "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "#real", steps[0].Selector, "CSS should win over aria/xpath")
|
||||
require.Equal(t, "xpath=//div[2]", steps[1].Selector, "xpath used when no CSS, pierce ignored")
|
||||
require.Equal(t, `[aria-label="Submit"]`, steps[2].Selector, "aria falls back to aria-label CSS")
|
||||
}
|
||||
|
||||
func TestStepsFromRecording_Errors(t *testing.T) {
|
||||
_, err := StepsFromRecording([]byte(`not json`), "", "")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = StepsFromRecording([]byte(`{"steps": []}`), "", "")
|
||||
require.Error(t, err, "empty recording must error")
|
||||
|
||||
_, err = StepsFromRecording([]byte(`{"steps": [{"type":"setViewport"}]}`), "", "")
|
||||
require.Error(t, err, "recording with no replayable steps must error")
|
||||
|
||||
_, err = StepsFromRecording([]byte(`{"steps": [{"type":"doubleClick","selectors":[["#x"]]}]}`), "", "")
|
||||
require.Error(t, err, "unsupported actions must fail the import")
|
||||
require.Contains(t, err.Error(), "unsupported recording action")
|
||||
|
||||
_, err = StepsFromRecording([]byte(`{"steps": [{"type":"waitForExpression","expression":"1==1"}]}`), "", "")
|
||||
require.Error(t, err, "expression waits must fail rather than become fixed waits")
|
||||
}
|
||||
|
||||
func TestFirstNavigateURL(t *testing.T) {
|
||||
steps := []LoginStep{
|
||||
{Action: "fill", Selector: "#x"},
|
||||
{Action: "navigate", Value: "https://app.example.com/login"},
|
||||
{Action: "navigate", Value: "https://second"},
|
||||
}
|
||||
require.Equal(t, "https://app.example.com/login", FirstNavigateURL(steps))
|
||||
require.Equal(t, "", FirstNavigateURL([]LoginStep{{Action: "click"}}))
|
||||
}
|
||||
|
||||
// TestLoginHeadless_FromRecording proves an end-to-end recorded login: a Chrome
|
||||
// recording is compiled to steps (one of them an XPath selector) and replayed by
|
||||
// the headless engine against a real browser to acquire the session.
|
||||
func TestLoginHeadless_FromRecording(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
// The "next" click uses an XPath selector to exercise the engine's XPath path.
|
||||
recording := fmt.Sprintf(`{"steps": [
|
||||
{"type": "navigate", "url": %q},
|
||||
{"type": "change", "value": "[email protected]", "selectors": [["#email"]]},
|
||||
{"type": "click", "selectors": [["xpath///*[@id=\"next\"]"]]},
|
||||
{"type": "waitForElement", "selectors": [["#password"]]},
|
||||
{"type": "change", "value": "p@ss", "selectors": [["#password"]]},
|
||||
{"type": "click", "selectors": [["#submit"]]}
|
||||
]}`, srv.URL+"/login")
|
||||
|
||||
steps, err := StepsFromRecording([]byte(recording), "[email protected]", "p@ss")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "xpath=//*[@id=\"next\"]", steps[2].Selector, "recorded xpath selector should be preserved")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: FirstNavigateURL(steps),
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: steps,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "recorded login should capture the session cookie")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"title": "playground spa login",
|
||||
"steps": [
|
||||
{
|
||||
"type": "setViewport",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"type": "navigate",
|
||||
"url": "__BASE__/auth/spa-login"
|
||||
},
|
||||
{
|
||||
"type": "change",
|
||||
"value": "[email protected]",
|
||||
"selectors": [
|
||||
["xpath///input[@id='email']"]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "change",
|
||||
"value": "nuclei-rocks",
|
||||
"selectors": [
|
||||
["#password"],
|
||||
["aria/Password"]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"selectors": [
|
||||
["aria/Sign in"],
|
||||
["#submit"],
|
||||
["xpath///button[@id='submit']"]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "Enter"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package authprovider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// autoLoginE2EApp is a hermetic, CSRF-protected, cookie-session web app used to
|
||||
// exercise the whole auto-login autonomy path through the real FileAuthProvider:
|
||||
// secrets-file parse -> dynamic validate -> auto-login callback -> form detect +
|
||||
// submit -> captured session applied as a cookie strategy -> reauth-on-401.
|
||||
//
|
||||
// Each successful login mints a *fresh* session token (sess-N), so a
|
||||
// re-authentication is observable as a changed applied cookie value.
|
||||
type autoLoginE2EApp struct {
|
||||
mu sync.Mutex
|
||||
loginCount int
|
||||
validToken string // the currently valid session token; "" means expired
|
||||
}
|
||||
|
||||
func (a *autoLoginE2EApp) server() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", a.handleLogin)
|
||||
mux.HandleFunc("/dashboard", a.handleDashboard)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func (a *autoLoginE2EApp) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
http.SetCookie(w, &http.Cookie{Name: "csrftoken", Value: "csrf-secret", Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="csrf" value="csrf-secret">
|
||||
<input type="email" name="email" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" name="commit" value="Sign in">
|
||||
</form></body></html>`)
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.ParseForm()
|
||||
csrf, err := r.Cookie("csrftoken")
|
||||
if err != nil || r.PostFormValue("csrf") != csrf.Value {
|
||||
http.Error(w, "csrf mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "s3cr3t" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.loginCount++
|
||||
a.validToken = fmt.Sprintf("sess-%d", a.loginCount)
|
||||
token := a.validToken
|
||||
a.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *autoLoginE2EApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
a.mu.Lock()
|
||||
valid := a.validToken
|
||||
a.mu.Unlock()
|
||||
if err != nil || valid == "" || c.Value != valid {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "welcome")
|
||||
}
|
||||
|
||||
func (a *autoLoginE2EApp) expire() {
|
||||
a.mu.Lock()
|
||||
a.validToken = ""
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// appliedSessionCookie applies the strategies to a fresh request and returns the
|
||||
// value of the "session" cookie they set (or "").
|
||||
func appliedSessionCookie(strategies []authx.AuthStrategy, target string) string {
|
||||
req, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
for _, s := range strategies {
|
||||
s.Apply(req)
|
||||
}
|
||||
if c, err := req.Cookie("session"); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAutoLogin_FullLifecycle_E2E(t *testing.T) {
|
||||
app := &autoLoginE2EApp{}
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
host := srv.Listener.Addr().String()
|
||||
secrets := fmt.Sprintf(`id: auto-login-e2e
|
||||
info:
|
||||
name: auto-login-e2e
|
||||
dynamic:
|
||||
- domains:
|
||||
- "%s"
|
||||
auto-login:
|
||||
login-url: "%s/login"
|
||||
username: "[email protected]"
|
||||
password: "s3cr3t"
|
||||
reauth-status-codes:
|
||||
- 401
|
||||
`, host, srv.URL)
|
||||
|
||||
dir := t.TempDir()
|
||||
secretsPath := filepath.Join(dir, "auth.yaml")
|
||||
require.NoError(t, os.WriteFile(secretsPath, []byte(secrets), 0o644))
|
||||
|
||||
// callback is nil: auto-login dynamics need no template store.
|
||||
provider, err := NewFileAuthProvider(secretsPath, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Pre-fetch performs the real form login.
|
||||
require.NoError(t, provider.PreFetchSecrets())
|
||||
|
||||
strategies := provider.LookupAddr(host)
|
||||
require.NotEmpty(t, strategies, "auto-login secret should be scoped to the app host")
|
||||
|
||||
// 1. The captured session authenticates against the protected route.
|
||||
cookie1 := appliedSessionCookie(strategies, srv.URL+"/dashboard")
|
||||
require.Equal(t, "sess-1", cookie1, "first login should apply session token sess-1")
|
||||
requireDashboard(t, srv.URL, cookie1, http.StatusOK)
|
||||
|
||||
// 2. Server-side session expiry: the applied cookie now 401s.
|
||||
app.expire()
|
||||
requireDashboard(t, srv.URL, cookie1, http.StatusUnauthorized)
|
||||
|
||||
// 3. Surface the 401 to the strategy (as the scan engine does), which marks
|
||||
// the dynamic session stale for re-authentication.
|
||||
reauthTriggered := false
|
||||
for _, s := range strategies {
|
||||
if insp, ok := s.(authx.ResponseInspector); ok {
|
||||
if insp.OnResponse(http.StatusUnauthorized, 0) {
|
||||
reauthTriggered = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, reauthTriggered, "401 should trigger re-authentication on the dynamic strategy")
|
||||
|
||||
// 4. The next application re-runs the form login and applies a *fresh*
|
||||
// session token, restoring access.
|
||||
cookie2 := appliedSessionCookie(strategies, srv.URL+"/dashboard")
|
||||
require.Equal(t, "sess-2", cookie2, "re-auth should apply the freshly minted session token")
|
||||
requireDashboard(t, srv.URL, cookie2, http.StatusOK)
|
||||
|
||||
require.Equal(t, 2, app.loginCount, "login form should have been submitted exactly twice")
|
||||
}
|
||||
|
||||
func requireDashboard(t *testing.T, baseURL, sessionCookie string, wantStatus int) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodGet, baseURL+"/dashboard", nil)
|
||||
if sessionCookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie})
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, wantStatus, resp.StatusCode)
|
||||
}
|
||||
@@ -21,16 +21,34 @@ type FileAuthProvider struct {
|
||||
}
|
||||
|
||||
// NewFileAuthProvider creates a new file based auth provider
|
||||
func NewFileAuthProvider(path string, callback authx.LazyFetchSecret) (AuthProvider, error) {
|
||||
func NewFileAuthProvider(path string, callback authx.LazyFetchSecret, autoLoginOpts *authx.AutoLoginRuntimeOptions) (AuthProvider, error) {
|
||||
store, err := authx.GetAuthDataFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(store.Secrets) == 0 && len(store.Dynamic) == 0 {
|
||||
return newProviderFromStore(path, store, callback, autoLoginOpts)
|
||||
}
|
||||
|
||||
// NewStoreAuthProvider builds an auth provider from an already-constructed Authx
|
||||
// store (e.g. one assembled from CLI flags rather than read from a file).
|
||||
func NewStoreAuthProvider(store *authx.Authx, callback authx.LazyFetchSecret, autoLoginOpts *authx.AutoLoginRuntimeOptions) (AuthProvider, error) {
|
||||
return newProviderFromStore("", store, callback, autoLoginOpts)
|
||||
}
|
||||
|
||||
// newProviderFromStore validates the store, installs fetch callbacks and builds
|
||||
// the domain lookup indexes shared by the file- and store-based constructors.
|
||||
func newProviderFromStore(path string, store *authx.Authx, callback authx.LazyFetchSecret, autoLoginOpts *authx.AutoLoginRuntimeOptions) (AuthProvider, error) {
|
||||
if store == nil || (len(store.Secrets) == 0 && len(store.Dynamic) == 0) {
|
||||
return nil, ErrNoSecrets
|
||||
}
|
||||
if len(store.Dynamic) > 0 && callback == nil {
|
||||
return nil, errkit.New("lazy fetch callback is required for dynamic secrets")
|
||||
if callback == nil {
|
||||
// The lazy fetch callback is only required for template-based dynamic
|
||||
// secrets; auto-login dynamics resolve their session without a template.
|
||||
for _, dynamic := range store.Dynamic {
|
||||
if dynamic.AutoLogin == nil {
|
||||
return nil, errkit.New("lazy fetch callback is required for dynamic secrets")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, secret := range store.Secrets {
|
||||
if err := secret.Validate(); err != nil {
|
||||
@@ -45,7 +63,13 @@ func NewFileAuthProvider(path string, callback authx.LazyFetchSecret) (AuthProvi
|
||||
errorErr.Msgf("invalid dynamic in file: %s", path)
|
||||
return nil, errorErr
|
||||
}
|
||||
dynamic.SetLazyFetchCallback(callback)
|
||||
if dynamic.AutoLogin != nil {
|
||||
// Auto-login resolves its session via the form-login engine rather
|
||||
// than a template, so it uses a dedicated callback.
|
||||
dynamic.SetAutoLoginCallback(autoLoginOpts)
|
||||
} else {
|
||||
dynamic.SetLazyFetchCallback(callback)
|
||||
}
|
||||
store.Dynamic[i] = dynamic
|
||||
}
|
||||
f := &FileAuthProvider{Path: path, store: store}
|
||||
@@ -192,3 +216,12 @@ func (f *FileAuthProvider) PreFetchSecrets() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close drops any in-memory session captured by dynamic (auto-login) secrets so
|
||||
// nuclei does not retain live session material after the scan. It is a no-op for
|
||||
// static secrets and never contacts the target.
|
||||
func (f *FileAuthProvider) Close() {
|
||||
for i := range f.store.Dynamic {
|
||||
f.store.Dynamic[i].Close()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ dynamic:
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
dynamic.Extracted = map[string]interface{}{"token": "session-token"}
|
||||
return nil
|
||||
})
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
const workers = 20
|
||||
|
||||
@@ -35,6 +35,11 @@ type AuthProvider interface {
|
||||
// PreFetchSecrets pre-fetches the secrets from the auth provider
|
||||
// instead of lazy fetching
|
||||
PreFetchSecrets() error
|
||||
// Close releases any in-memory session material captured during the scan
|
||||
// (e.g. cookies/tokens/web storage obtained by an auto-login dynamic
|
||||
// secret). It must be safe to call once at scan teardown and a no-op for
|
||||
// providers that hold only static secrets. It never contacts the target.
|
||||
Close()
|
||||
}
|
||||
|
||||
// AuthProviderOptions contains options for the auth provider
|
||||
@@ -43,13 +48,16 @@ type AuthProviderOptions struct {
|
||||
SecretsFiles []string
|
||||
// LazyFetchSecret is a callback for lazy fetching of dynamic secrets
|
||||
LazyFetchSecret authx.LazyFetchSecret
|
||||
// AutoLoginOptions carries scan-level browser/runtime configuration into
|
||||
// auto-login dynamic secrets (user-agent, headers, proxy, CDP, etc.). May be nil.
|
||||
AutoLoginOptions *authx.AutoLoginRuntimeOptions
|
||||
}
|
||||
|
||||
// NewAuthProvider creates a new auth provider from the given options
|
||||
func NewAuthProvider(options *AuthProviderOptions) (AuthProvider, error) {
|
||||
var providers []AuthProvider
|
||||
for _, file := range options.SecretsFiles {
|
||||
provider, err := NewFileAuthProvider(file, options.LazyFetchSecret)
|
||||
provider, err := NewFileAuthProvider(file, options.LazyFetchSecret, options.AutoLoginOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -65,3 +65,11 @@ func (m *MultiAuthProvider) PreFetchSecrets() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases any in-memory session held by the wrapped providers (e.g.
|
||||
// auto-login dynamic secrets).
|
||||
func (m *MultiAuthProvider) Close() {
|
||||
for _, provider := range m.Providers {
|
||||
provider.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
)
|
||||
|
||||
// prepareAuthForNavigation refreshes authentication material before a
|
||||
// same-origin navigation and installs cookies using the browser's native domain
|
||||
// semantics. Authentication headers are applied separately to each intercepted
|
||||
// request and are never installed as page-global extra headers.
|
||||
func (p *Page) prepareAuthForNavigation(target *urlutil.URL) {
|
||||
if p.options == nil || p.options.AuthProvider == nil || target == nil || target.URL == nil || !p.authOriginMatches(target.URL) {
|
||||
return
|
||||
}
|
||||
|
||||
_, cookies, generation := resolveAuthMaterial(p.options.AuthProvider, target)
|
||||
p.setAuthSessionGeneration(generation)
|
||||
|
||||
if len(cookies) == 0 {
|
||||
if p.takeAuthRefreshPending() {
|
||||
p.applyAuthWebStorage()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p.page != nil {
|
||||
params := make([]*proto.NetworkCookieParam, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
param := &proto.NetworkCookieParam{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
URL: target.String(),
|
||||
Secure: cookie.Secure,
|
||||
}
|
||||
if cookie.Domain != "" {
|
||||
param.Domain = cookie.Domain
|
||||
}
|
||||
if cookie.Path != "" {
|
||||
param.Path = cookie.Path
|
||||
}
|
||||
params = append(params, param)
|
||||
}
|
||||
if err := p.page.SetCookies(params); err != nil {
|
||||
gologger.Warning().Msgf("headless: could not set auth cookies for %s: %s", target.String(), err)
|
||||
}
|
||||
}
|
||||
if !p.options.DisableCookie && p.ctx != nil && p.ctx.CookieJar != nil {
|
||||
p.ctx.CookieJar.SetCookies(target.URL, cookies)
|
||||
}
|
||||
if p.takeAuthRefreshPending() {
|
||||
p.applyAuthWebStorage()
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuthHeaders resolves and applies authentication headers to one request.
|
||||
// The explicit origin check is intentionally stricter than provider domain
|
||||
// matching: credentials selected for the page's target origin must never follow
|
||||
// a cross-origin subresource or navigation.
|
||||
func (p *Page) applyAuthHeaders(req *http.Request) []string {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil
|
||||
}
|
||||
headers := p.authHeaders(req.URL)
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
if i == 0 || !strings.EqualFold(headers[i], headers[i-2]) {
|
||||
req.Header.Del(headers[i])
|
||||
}
|
||||
req.Header.Add(headers[i], headers[i+1])
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func (p *Page) authHeaders(targetURL *url.URL) []string {
|
||||
if targetURL == nil || p.options == nil || p.options.AuthProvider == nil || !p.authOriginMatches(targetURL) {
|
||||
return nil
|
||||
}
|
||||
target, err := urlutil.Parse(targetURL.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
headers, _, generation := resolveAuthMaterial(p.options.AuthProvider, target)
|
||||
p.setAuthSessionGeneration(generation)
|
||||
return headers
|
||||
}
|
||||
|
||||
func (p *Page) authOriginMatches(target *url.URL) bool {
|
||||
if p == nil || p.inputURL == nil || p.inputURL.URL == nil {
|
||||
return false
|
||||
}
|
||||
return sameOrigin(p.inputURL.URL, target)
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil || !strings.EqualFold(left.Scheme, right.Scheme) || !strings.EqualFold(left.Hostname(), right.Hostname()) {
|
||||
return false
|
||||
}
|
||||
return effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(value *url.URL) string {
|
||||
if port := value.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(value.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Page) setAuthSessionGeneration(generation uint64) {
|
||||
p.authMutex.Lock()
|
||||
p.authSessionGeneration = generation
|
||||
p.authMutex.Unlock()
|
||||
}
|
||||
|
||||
func (p *Page) currentAuthSessionGeneration() uint64 {
|
||||
p.authMutex.RLock()
|
||||
defer p.authMutex.RUnlock()
|
||||
return p.authSessionGeneration
|
||||
}
|
||||
|
||||
func (p *Page) markAuthRefreshPending() {
|
||||
p.authMutex.Lock()
|
||||
p.authRefreshPending = true
|
||||
p.authMutex.Unlock()
|
||||
}
|
||||
|
||||
func (p *Page) takeAuthRefreshPending() bool {
|
||||
p.authMutex.Lock()
|
||||
defer p.authMutex.Unlock()
|
||||
pending := p.authRefreshPending
|
||||
p.authRefreshPending = false
|
||||
return pending
|
||||
}
|
||||
|
||||
// notifyAuthResponse forwards the main navigation response status to any auth
|
||||
// strategies that inspect responses (e.g. dynamic/auto-login secrets), so an
|
||||
// expired session (a status listed in reauth-status-codes) is marked stale and
|
||||
// re-authenticated before the next headless navigation. This mirrors the HTTP
|
||||
// protocol's NotifyResponse behaviour.
|
||||
func (p *Page) notifyAuthResponse(statusCode int) {
|
||||
if p.options == nil || p.options.AuthProvider == nil || p.inputURL == nil {
|
||||
return
|
||||
}
|
||||
for _, strategy := range p.options.AuthProvider.LookupURLX(p.inputURL) {
|
||||
if inspector, ok := strategy.(authx.ResponseInspector); ok {
|
||||
if inspector.OnResponse(statusCode, p.currentAuthSessionGeneration()) {
|
||||
p.markAuthRefreshPending()
|
||||
gologger.Verbose().Msgf("[authprovider] Session expired (status %d) for %s, will re-authenticate", statusCode, p.inputURL.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuthWebStorage seeds browser web storage (localStorage/sessionStorage)
|
||||
// captured by a headless auto-login into the page. Because web storage is
|
||||
// origin-scoped and only exists once a document for the origin is loaded, it is
|
||||
// injected via an on-new-document script (guarded by origin) that runs before
|
||||
// page scripts on every navigation — so client-side code that reads its token
|
||||
// from storage behaves as if logged in.
|
||||
func (p *Page) applyAuthWebStorage() {
|
||||
if p.options == nil || p.options.AuthProvider == nil || p.inputURL == nil {
|
||||
return
|
||||
}
|
||||
local, session := resolveBrowserStorage(p.options.AuthProvider, p.inputURL)
|
||||
if len(local) == 0 && len(session) == 0 {
|
||||
return
|
||||
}
|
||||
origin := ""
|
||||
if u := p.inputURL.URL; u != nil {
|
||||
origin = u.Scheme + "://" + u.Host
|
||||
}
|
||||
js := buildStorageInjectorJS(origin, local, session)
|
||||
if js == "" {
|
||||
return
|
||||
}
|
||||
if _, err := p.page.EvalOnNewDocument(js); err != nil {
|
||||
gologger.Warning().Msgf("headless: could not seed web storage for %s: %s", p.inputURL.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBrowserStorage merges the web storage carried by any
|
||||
// BrowserStorageProvider strategy resolved for the target URL.
|
||||
func resolveBrowserStorage(provider authprovider.AuthProvider, target *urlutil.URL) (local map[string]string, session map[string]string) {
|
||||
if provider == nil || target == nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, strategy := range provider.LookupURLX(target) {
|
||||
sp, ok := strategy.(authx.BrowserStorageProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
l, s := sp.WebStorage()
|
||||
for k, v := range l {
|
||||
if local == nil {
|
||||
local = map[string]string{}
|
||||
}
|
||||
local[k] = v
|
||||
}
|
||||
for k, v := range s {
|
||||
if session == nil {
|
||||
session = map[string]string{}
|
||||
}
|
||||
session[k] = v
|
||||
}
|
||||
}
|
||||
return local, session
|
||||
}
|
||||
|
||||
// buildStorageInjectorJS builds an on-new-document script that, only when the
|
||||
// document origin matches, seeds the given localStorage/sessionStorage items.
|
||||
func buildStorageInjectorJS(origin string, local, session map[string]string) string {
|
||||
localJSON, err := json.Marshal(local)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sessionJSON, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
originJSON, err := json.Marshal(origin)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// EvalOnNewDocument evaluates the source directly (it is not invoked as a
|
||||
// function), so this must be a self-executing statement. It is defensive: it
|
||||
// no-ops on origin mismatch and swallows errors (e.g. storage disabled) so it
|
||||
// can never break the navigation.
|
||||
return fmt.Sprintf(`(function () {
|
||||
try {
|
||||
if (%s && window.location && window.location.origin !== %s) { return; }
|
||||
var l = %s, s = %s, k;
|
||||
for (k in l) { try { window.localStorage.setItem(k, l[k]); } catch (e) {} }
|
||||
for (k in s) { try { window.sessionStorage.setItem(k, s[k]); } catch (e) {} }
|
||||
} catch (e) {}
|
||||
})();`, originJSON, originJSON, localJSON, sessionJSON)
|
||||
}
|
||||
|
||||
// resolveAuthMaterial resolves the auth strategies for the given URL into a flat
|
||||
// list of header key/value pairs (suitable for rod's SetExtraHeaders, excluding
|
||||
// the Cookie header) and the cookies to set on the browser.
|
||||
//
|
||||
// It is kept free of any browser dependency so it can be unit-tested in
|
||||
// isolation. The header/cookie values are produced by applying the strategies to
|
||||
// a synthetic request, guaranteeing parity with the HTTP protocol's behaviour.
|
||||
func resolveAuthMaterial(provider authprovider.AuthProvider, target *urlutil.URL) (headers []string, cookies []*http.Cookie, generation uint64) {
|
||||
if provider == nil || target == nil {
|
||||
return nil, nil, 0
|
||||
}
|
||||
strategies := provider.LookupURLX(target)
|
||||
if len(strategies) == 0 {
|
||||
return nil, nil, 0
|
||||
}
|
||||
|
||||
synthetic, err := http.NewRequest(http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("headless: could not build auth request for %s: %s", target.String(), err)
|
||||
return nil, nil, 0
|
||||
}
|
||||
for _, strategy := range strategies {
|
||||
if strategy != nil {
|
||||
strategy.Apply(synthetic)
|
||||
}
|
||||
}
|
||||
generation = authx.SessionGenerationFromRequest(synthetic)
|
||||
|
||||
for key, values := range synthetic.Header {
|
||||
if strings.EqualFold(key, "Cookie") {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
headers = append(headers, key, value)
|
||||
}
|
||||
}
|
||||
return headers, synthetic.Cookies(), generation
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// secretsYAML is a real nuclei auth secrets file with a header+cookie secret
|
||||
// scoped to app.example.com (and a regex domain), used to exercise the headless
|
||||
// auth-fusion seam through the production FileAuthProvider rather than a mock.
|
||||
const secretsYAML = `id: headless-auth-e2e
|
||||
info:
|
||||
name: headless auth e2e
|
||||
author: pdteam
|
||||
severity: info
|
||||
static:
|
||||
- type: header
|
||||
domains:
|
||||
- app.example.com
|
||||
domains-regex:
|
||||
- "^api\\.example\\.com$"
|
||||
headers:
|
||||
- key: X-Api-Key
|
||||
value: s3cr3t-key
|
||||
- type: cookie
|
||||
domains:
|
||||
- app.example.com
|
||||
cookies:
|
||||
- key: session
|
||||
value: abc123
|
||||
`
|
||||
|
||||
func writeSecretsFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "secrets.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(secretsYAML), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
// TestHeadlessAuthFusion_RealProvider_E2E builds a real file-based auth provider
|
||||
// and drives the headless fusion seam (resolveAuthMaterial) end to end: it
|
||||
// verifies that the provider's domain-scoped secrets are correctly materialized
|
||||
// into the extra headers and cookies the browser would receive, and that
|
||||
// non-matching domains yield nothing. This is the browser-free core of the
|
||||
// headless+auth integration and runs reliably in CI.
|
||||
func TestHeadlessAuthFusion_RealProvider_E2E(t *testing.T) {
|
||||
provider, err := authprovider.NewFileAuthProvider(writeSecretsFile(t), nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("matching domain materializes header and cookie", func(t *testing.T) {
|
||||
headers, cookies, _ := resolveAuthMaterial(provider, mustParseURL(t, "https://app.example.com/dashboard"))
|
||||
|
||||
headerMap := pairsToMap(t, headers)
|
||||
require.Equal(t, "s3cr3t-key", headerMap["X-Api-Key"])
|
||||
_, hasCookieHeader := headerMap["Cookie"]
|
||||
require.False(t, hasCookieHeader, "Cookie must be delivered as a browser cookie, not an extra header")
|
||||
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, "session", cookies[0].Name)
|
||||
require.Equal(t, "abc123", cookies[0].Value)
|
||||
})
|
||||
|
||||
t.Run("regex domain matches", func(t *testing.T) {
|
||||
headers, _, _ := resolveAuthMaterial(provider, mustParseURL(t, "https://api.example.com/v1"))
|
||||
headerMap := pairsToMap(t, headers)
|
||||
require.Equal(t, "s3cr3t-key", headerMap["X-Api-Key"], "regex-scoped secret must apply to api.example.com")
|
||||
})
|
||||
|
||||
t.Run("non-matching domain yields nothing", func(t *testing.T) {
|
||||
headers, cookies, _ := resolveAuthMaterial(provider, mustParseURL(t, "https://evil.example.org/"))
|
||||
require.Empty(t, headers, "no credentials should be sent to an out-of-scope domain")
|
||||
require.Empty(t, cookies, "no cookies should be sent to an out-of-scope domain")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeadlessAuthHeaders_DoNotLeakCrossOrigin(t *testing.T) {
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless auth header e2e")
|
||||
}
|
||||
|
||||
opts := &types.Options{AllowLocalFileAccess: true}
|
||||
require.NoError(t, protocolstate.Init(opts))
|
||||
|
||||
browser, err := New(&types.Options{ShowBrowser: false, UseInstalledChrome: true})
|
||||
require.NoError(t, err)
|
||||
defer browser.Close()
|
||||
|
||||
instance, err := browser.NewInstance()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = instance.Close() }()
|
||||
|
||||
crossOriginHeader := make(chan string, 1)
|
||||
crossOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case crossOriginHeader <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
_, _ = fmt.Fprintln(w, "window.crossOriginLoaded = true;")
|
||||
}))
|
||||
defer crossOrigin.Close()
|
||||
|
||||
sameOriginHeader := make(chan string, 1)
|
||||
sameOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case sameOriginHeader <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body><script src="%s/resource.js"></script></body></html>`, crossOrigin.URL)
|
||||
}))
|
||||
defer sameOrigin.Close()
|
||||
|
||||
input := contextargs.NewWithInput(context.Background(), sameOrigin.URL)
|
||||
input.CookieJar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
provider := &mockAuthProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewHeadersAuthStrategy(&authx.Secret{Headers: []authx.KV{{Key: "Authorization", Value: "Bearer secret"}}}),
|
||||
}}
|
||||
|
||||
_, page, err := instance.Run(input, actions, nil, &Options{
|
||||
Timeout: 30 * time.Second,
|
||||
Options: opts,
|
||||
AuthProvider: provider,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if page != nil {
|
||||
defer page.Close()
|
||||
}
|
||||
|
||||
select {
|
||||
case header := <-sameOriginHeader:
|
||||
require.Equal(t, "Bearer secret", header)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("same-origin document request was not observed")
|
||||
}
|
||||
select {
|
||||
case header := <-crossOriginHeader:
|
||||
require.Empty(t, header, "auth header leaked to cross-origin subresource")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cross-origin subresource request was not observed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// recordingInspector is an AuthStrategy + ResponseInspector that records the
|
||||
// status codes the engine forwards to it, to verify response-status re-auth.
|
||||
type recordingInspector struct {
|
||||
mu sync.Mutex
|
||||
statuses []int
|
||||
reauthOn int
|
||||
}
|
||||
|
||||
func (r *recordingInspector) Apply(*http.Request) {}
|
||||
func (r *recordingInspector) ApplyOnRR(*retryablehttp.Request) {}
|
||||
func (r *recordingInspector) OnResponse(status int, _ uint64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statuses = append(r.statuses, status)
|
||||
return status == r.reauthOn
|
||||
}
|
||||
func (r *recordingInspector) seen() []int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]int(nil), r.statuses...)
|
||||
}
|
||||
|
||||
// inspectorProvider returns the recording inspector for any URL.
|
||||
type inspectorProvider struct{ insp *recordingInspector }
|
||||
|
||||
func (p *inspectorProvider) LookupAddr(string) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{p.insp}
|
||||
}
|
||||
func (p *inspectorProvider) LookupURL(*url.URL) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{p.insp}
|
||||
}
|
||||
func (p *inspectorProvider) LookupURLX(*urlutil.URL) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{p.insp}
|
||||
}
|
||||
func (p *inspectorProvider) GetTemplatePaths() []string { return nil }
|
||||
func (p *inspectorProvider) PreFetchSecrets() error { return nil }
|
||||
func (p *inspectorProvider) Close() {}
|
||||
|
||||
func TestNotifyAuthResponse(t *testing.T) {
|
||||
insp := &recordingInspector{reauthOn: 401}
|
||||
target, _ := urlutil.Parse("https://app.example.com/")
|
||||
p := &Page{
|
||||
options: &Options{AuthProvider: &inspectorProvider{insp: insp}},
|
||||
inputURL: target,
|
||||
authMutex: &sync.RWMutex{},
|
||||
}
|
||||
p.notifyAuthResponse(401)
|
||||
p.notifyAuthResponse(200)
|
||||
require.Equal(t, []int{401, 200}, insp.seen(), "every navigation status must be forwarded to the inspector")
|
||||
}
|
||||
|
||||
func TestNotifyAuthResponse_NilSafe(t *testing.T) {
|
||||
// Must not panic with no provider / no input URL.
|
||||
(&Page{}).notifyAuthResponse(401)
|
||||
(&Page{options: &Options{}}).notifyAuthResponse(401)
|
||||
}
|
||||
|
||||
// TestNotifyAuthResponse_E2E proves the full chain: a real navigation that
|
||||
// returns 401 is forwarded to the auth provider's response inspector, so
|
||||
// reauth-status-codes works for headless scans (not just HTTP).
|
||||
func TestNotifyAuthResponse_E2E(t *testing.T) {
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless reauth e2e")
|
||||
}
|
||||
|
||||
opts := &types.Options{AllowLocalFileAccess: true}
|
||||
require.NoError(t, protocolstate.Init(opts))
|
||||
|
||||
browser, err := New(&types.Options{ShowBrowser: false, UseInstalledChrome: true})
|
||||
require.NoError(t, err)
|
||||
defer browser.Close()
|
||||
|
||||
instance, err := browser.NewInstance()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = instance.Close() }()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = fmt.Fprintln(w, "<html><body>unauthorized</body></html>")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
insp := &recordingInspector{reauthOn: http.StatusUnauthorized}
|
||||
input := contextargs.NewWithInput(context.Background(), ts.URL)
|
||||
input.CookieJar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
|
||||
_, p, err := instance.Run(input, actions, nil, &Options{
|
||||
Timeout: 30 * time.Second,
|
||||
Options: opts,
|
||||
AuthProvider: &inspectorProvider{insp: insp},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
if p != nil {
|
||||
p.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
require.Contains(t, insp.seen(), http.StatusUnauthorized, "401 navigation must be forwarded to the auth response inspector")
|
||||
}
|
||||
|
||||
// rotatingCookieProvider applies a cookie whose value rotates after each 401,
|
||||
// modelling a dynamic secret that re-authenticates between navigations.
|
||||
type rotatingCookieProvider struct {
|
||||
token atomic.Int32
|
||||
reauthed atomic.Bool
|
||||
}
|
||||
|
||||
func (r *rotatingCookieProvider) current() string {
|
||||
return fmt.Sprintf("tok-%d", r.token.Load())
|
||||
}
|
||||
|
||||
func (r *rotatingCookieProvider) Apply(req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: r.current()})
|
||||
}
|
||||
func (r *rotatingCookieProvider) ApplyOnRR(req *retryablehttp.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: r.current()})
|
||||
}
|
||||
func (r *rotatingCookieProvider) OnResponse(status int, _ uint64) bool {
|
||||
if status != http.StatusUnauthorized {
|
||||
return false
|
||||
}
|
||||
r.token.Add(1)
|
||||
r.reauthed.Store(true)
|
||||
return true
|
||||
}
|
||||
func (r *rotatingCookieProvider) LookupAddr(string) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{r}
|
||||
}
|
||||
func (r *rotatingCookieProvider) LookupURL(*url.URL) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{r}
|
||||
}
|
||||
func (r *rotatingCookieProvider) LookupURLX(*urlutil.URL) []authx.AuthStrategy {
|
||||
return []authx.AuthStrategy{r}
|
||||
}
|
||||
func (r *rotatingCookieProvider) GetTemplatePaths() []string { return nil }
|
||||
func (r *rotatingCookieProvider) PreFetchSecrets() error { return nil }
|
||||
func (r *rotatingCookieProvider) Close() {}
|
||||
|
||||
// TestAuthReauth_RefreshThenNavigate proves a 401 triggers credential refresh
|
||||
// and a subsequent navigation succeeds with the rotated session cookie.
|
||||
func TestAuthReauth_RefreshThenNavigate(t *testing.T) {
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless reauth e2e")
|
||||
}
|
||||
|
||||
opts := &types.Options{AllowLocalFileAccess: true}
|
||||
require.NoError(t, protocolstate.Init(opts))
|
||||
|
||||
browser, err := New(&types.Options{ShowBrowser: false, UseInstalledChrome: true})
|
||||
require.NoError(t, err)
|
||||
defer browser.Close()
|
||||
|
||||
provider := &rotatingCookieProvider{}
|
||||
provider.token.Store(1) // first applied cookie is tok-1
|
||||
|
||||
var sawOK atomic.Bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil || c.Value != "tok-2" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = fmt.Fprintln(w, "<html><body>unauthorized</body></html>")
|
||||
return
|
||||
}
|
||||
sawOK.Store(true)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintln(w, "<html><body>ok</body></html>")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
instance, err := browser.NewInstance()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = instance.Close() }()
|
||||
|
||||
input := contextargs.NewWithInput(context.Background(), ts.URL)
|
||||
input.CookieJar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
|
||||
// First navigation: tok-1 is rejected with 401 → provider rotates to tok-2.
|
||||
_, page, err := instance.Run(input, actions, nil, &Options{
|
||||
Timeout: 30 * time.Second,
|
||||
Options: opts,
|
||||
AuthProvider: provider,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, page)
|
||||
defer page.Close()
|
||||
require.True(t, provider.reauthed.Load(), "401 must trigger credential refresh")
|
||||
require.Equal(t, int32(2), provider.token.Load())
|
||||
|
||||
// The same Page must install tok-2 before its next navigation.
|
||||
_, err = page.ExecuteActions(input, actions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, sawOK.Load(), "second navigation must succeed after refreshed credentials")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeStorageProvider is a minimal AuthProvider returning a fixed set of
|
||||
// strategies for any URL, used to unit-test storage resolution without a browser.
|
||||
type fakeStorageProvider struct {
|
||||
strategies []authx.AuthStrategy
|
||||
}
|
||||
|
||||
func (f *fakeStorageProvider) LookupAddr(string) []authx.AuthStrategy { return f.strategies }
|
||||
func (f *fakeStorageProvider) LookupURL(*url.URL) []authx.AuthStrategy { return f.strategies }
|
||||
func (f *fakeStorageProvider) LookupURLX(*urlutil.URL) []authx.AuthStrategy { return f.strategies }
|
||||
func (f *fakeStorageProvider) GetTemplatePaths() []string { return nil }
|
||||
func (f *fakeStorageProvider) PreFetchSecrets() error { return nil }
|
||||
func (f *fakeStorageProvider) Close() {}
|
||||
|
||||
func TestResolveBrowserStorage(t *testing.T) {
|
||||
provider := &fakeStorageProvider{strategies: []authx.AuthStrategy{
|
||||
// A non-storage strategy must be ignored.
|
||||
authx.NewBearerTokenAuthStrategy(&authx.Secret{Type: "BearerToken", Token: "t"}),
|
||||
authx.NewWebStorageAuthStrategy(
|
||||
map[string]string{"jwt": "eyJ.payload.sig"},
|
||||
map[string]string{"csrf": "tok"},
|
||||
),
|
||||
}}
|
||||
target, _ := urlutil.Parse("https://app.example.com/dashboard")
|
||||
|
||||
local, session := resolveBrowserStorage(provider, target)
|
||||
require.Equal(t, "eyJ.payload.sig", local["jwt"])
|
||||
require.Equal(t, "tok", session["csrf"])
|
||||
}
|
||||
|
||||
// TestResolveBrowserStorage_FromStaticSecret proves the capture-once parity
|
||||
// wiring end to end at the provider layer: a static WebStorage secret (as built
|
||||
// from a captured session) flows through a real AuthProvider and is surfaced to
|
||||
// the headless storage resolver.
|
||||
func TestResolveBrowserStorage_FromStaticSecret(t *testing.T) {
|
||||
store := &authx.Authx{
|
||||
ID: "capture",
|
||||
Secrets: []authx.Secret{{
|
||||
Type: string(authx.WebStorageAuth),
|
||||
Domains: []string{"app.example.com"},
|
||||
LocalStorage: map[string]string{"jwt": "SEEDED"},
|
||||
SessionStorage: map[string]string{"csrf": "TOK"},
|
||||
}},
|
||||
}
|
||||
provider, err := authprovider.NewStoreAuthProvider(store, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
target, _ := urlutil.Parse("https://app.example.com/dashboard")
|
||||
local, session := resolveBrowserStorage(provider, target)
|
||||
require.Equal(t, "SEEDED", local["jwt"])
|
||||
require.Equal(t, "TOK", session["csrf"])
|
||||
|
||||
// A different host must not receive the captured storage.
|
||||
other, _ := urlutil.Parse("https://other.example.com/")
|
||||
l2, s2 := resolveBrowserStorage(provider, other)
|
||||
require.Nil(t, l2)
|
||||
require.Nil(t, s2)
|
||||
}
|
||||
|
||||
func TestResolveBrowserStorage_NoStorageStrategies(t *testing.T) {
|
||||
provider := &fakeStorageProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewBearerTokenAuthStrategy(&authx.Secret{Type: "BearerToken", Token: "t"}),
|
||||
}}
|
||||
target, _ := urlutil.Parse("https://app.example.com/")
|
||||
local, session := resolveBrowserStorage(provider, target)
|
||||
require.Nil(t, local)
|
||||
require.Nil(t, session)
|
||||
}
|
||||
|
||||
func TestBuildStorageInjectorJS(t *testing.T) {
|
||||
js := buildStorageInjectorJS("https://app.example.com",
|
||||
map[string]string{"jwt": "abc"},
|
||||
map[string]string{"csrf": "xyz"},
|
||||
)
|
||||
require.Contains(t, js, `"https://app.example.com"`, "must guard on origin")
|
||||
require.Contains(t, js, "window.location.origin", "must compare document origin")
|
||||
require.Contains(t, js, "localStorage.setItem", "must set localStorage")
|
||||
require.Contains(t, js, "sessionStorage.setItem", "must set sessionStorage")
|
||||
require.Contains(t, js, `"jwt":"abc"`)
|
||||
require.Contains(t, js, `"csrf":"xyz"`)
|
||||
// Must be a self-executing statement (EvalOnNewDocument runs the source
|
||||
// directly, so a bare function would never execute).
|
||||
require.True(t, strings.HasPrefix(strings.TrimSpace(js), "(function"))
|
||||
require.True(t, strings.HasSuffix(strings.TrimSpace(js), "})();"))
|
||||
}
|
||||
|
||||
// TestApplyAuthWebStorage_E2E proves end to end that captured web storage is
|
||||
// seeded into a real scanned page before its scripts run: the served page reads
|
||||
// localStorage on load, and the value must be the one carried by the auth
|
||||
// provider's WebStorageAuthStrategy. Skipped without a system Chrome.
|
||||
func TestApplyAuthWebStorage_E2E(t *testing.T) {
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless storage-replay e2e")
|
||||
}
|
||||
|
||||
const page = `<html><body><div id="out"></div>
|
||||
<script>document.getElementById('out').innerText = (window.localStorage.getItem('jwt') || 'none') + '|' + (window.sessionStorage.getItem('csrf') || 'none');</script>
|
||||
</body></html>`
|
||||
|
||||
opts := &types.Options{AllowLocalFileAccess: true}
|
||||
require.NoError(t, protocolstate.Init(opts))
|
||||
|
||||
browser, err := New(&types.Options{ShowBrowser: false, UseInstalledChrome: true})
|
||||
require.NoError(t, err, "could not create browser")
|
||||
defer browser.Close()
|
||||
|
||||
instance, err := browser.NewInstance()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = instance.Close() }()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, page)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
provider := &fakeStorageProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewWebStorageAuthStrategy(
|
||||
map[string]string{"jwt": "SEEDED-JWT"},
|
||||
map[string]string{"csrf": "SEEDED-CSRF"},
|
||||
),
|
||||
}}
|
||||
|
||||
input := contextargs.NewWithInput(context.Background(), ts.URL)
|
||||
input.CookieJar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
|
||||
_, p, err := instance.Run(input, actions, nil, &Options{
|
||||
Timeout: 30 * time.Second,
|
||||
Options: opts,
|
||||
AuthProvider: provider,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
if p != nil {
|
||||
p.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := p.Page().MustElement("#out").Text()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "SEEDED-JWT|SEEDED-CSRF", strings.TrimSpace(out), "page should read the seeded local/session storage")
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockAuthProvider is a minimal AuthProvider that returns a fixed set of
|
||||
// strategies for any URL, used to exercise resolveAuthMaterial without a file.
|
||||
type mockAuthProvider struct {
|
||||
strategies []authx.AuthStrategy
|
||||
}
|
||||
|
||||
func (m *mockAuthProvider) LookupAddr(string) []authx.AuthStrategy { return m.strategies }
|
||||
func (m *mockAuthProvider) LookupURL(*url.URL) []authx.AuthStrategy { return m.strategies }
|
||||
func (m *mockAuthProvider) LookupURLX(*urlutil.URL) []authx.AuthStrategy { return m.strategies }
|
||||
func (m *mockAuthProvider) GetTemplatePaths() []string { return nil }
|
||||
func (m *mockAuthProvider) PreFetchSecrets() error { return nil }
|
||||
func (m *mockAuthProvider) Close() {}
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *urlutil.URL {
|
||||
t.Helper()
|
||||
u, err := urlutil.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestResolveAuthMaterial(t *testing.T) {
|
||||
t.Run("headers, bearer and cookies", func(t *testing.T) {
|
||||
provider := &mockAuthProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewHeadersAuthStrategy(&authx.Secret{Headers: []authx.KV{{Key: "X-Api-Key", Value: "secret"}}}),
|
||||
authx.NewBearerTokenAuthStrategy(&authx.Secret{Token: "tok123"}),
|
||||
authx.NewCookiesAuthStrategy(&authx.Secret{Cookies: []authx.Cookie{{Key: "session", Value: "abc"}}}),
|
||||
}}
|
||||
|
||||
headers, cookies, _ := resolveAuthMaterial(provider, mustParseURL(t, "https://example.com/app"))
|
||||
|
||||
headerMap := pairsToMap(t, headers)
|
||||
require.Equal(t, "secret", headerMap["X-Api-Key"])
|
||||
require.Equal(t, "Bearer tok123", headerMap["Authorization"])
|
||||
// the Cookie header must not leak into extra headers
|
||||
_, hasCookieHeader := headerMap["Cookie"]
|
||||
require.False(t, hasCookieHeader, "Cookie header must be handled as a cookie, not an extra header")
|
||||
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, "session", cookies[0].Name)
|
||||
require.Equal(t, "abc", cookies[0].Value)
|
||||
})
|
||||
|
||||
t.Run("no strategies returns nothing", func(t *testing.T) {
|
||||
headers, cookies, _ := resolveAuthMaterial(&mockAuthProvider{}, mustParseURL(t, "https://example.com/"))
|
||||
require.Empty(t, headers)
|
||||
require.Empty(t, cookies)
|
||||
})
|
||||
|
||||
t.Run("nil provider is safe", func(t *testing.T) {
|
||||
headers, cookies, _ := resolveAuthMaterial(nil, mustParseURL(t, "https://example.com/"))
|
||||
require.Nil(t, headers)
|
||||
require.Nil(t, cookies)
|
||||
})
|
||||
|
||||
t.Run("nil url is safe", func(t *testing.T) {
|
||||
headers, cookies, _ := resolveAuthMaterial(&mockAuthProvider{}, nil)
|
||||
require.Nil(t, headers)
|
||||
require.Nil(t, cookies)
|
||||
})
|
||||
|
||||
t.Run("only cookies yields no extra headers", func(t *testing.T) {
|
||||
provider := &mockAuthProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewCookiesAuthStrategy(&authx.Secret{Cookies: []authx.Cookie{{Key: "sid", Value: "xyz"}}}),
|
||||
}}
|
||||
headers, cookies, _ := resolveAuthMaterial(provider, mustParseURL(t, "https://example.com/"))
|
||||
require.Empty(t, headers)
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, "sid", cookies[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAuthHeaders_OriginScoped(t *testing.T) {
|
||||
provider := &mockAuthProvider{strategies: []authx.AuthStrategy{
|
||||
authx.NewHeadersAuthStrategy(&authx.Secret{Headers: []authx.KV{{Key: "Authorization", Value: "Bearer secret"}}}),
|
||||
}}
|
||||
p := &Page{
|
||||
options: &Options{AuthProvider: provider},
|
||||
inputURL: mustParseURL(t, "https://app.example.com:8443/start"),
|
||||
authMutex: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
t.Run("same origin receives auth", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://app.example.com:8443/api", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "browser-value")
|
||||
|
||||
p.applyAuthHeaders(req)
|
||||
|
||||
require.Equal(t, "Bearer secret", req.Header.Get("Authorization"))
|
||||
})
|
||||
|
||||
t.Run("different host does not receive auth", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com:8443/image.png", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
p.applyAuthHeaders(req)
|
||||
|
||||
require.Empty(t, req.Header.Get("Authorization"))
|
||||
})
|
||||
|
||||
t.Run("different port does not receive auth", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://app.example.com:9443/image.png", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
p.applyAuthHeaders(req)
|
||||
|
||||
require.Empty(t, req.Header.Get("Authorization"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSameOrigin_DefaultPorts(t *testing.T) {
|
||||
require.True(t, sameOrigin(
|
||||
mustParseURL(t, "https://example.com/path").URL,
|
||||
mustParseURL(t, "https://example.com:443/other").URL,
|
||||
))
|
||||
require.False(t, sameOrigin(
|
||||
mustParseURL(t, "http://example.com/path").URL,
|
||||
mustParseURL(t, "https://example.com/path").URL,
|
||||
))
|
||||
}
|
||||
|
||||
func TestAuthRedirectClient_StripsHeadersCrossOrigin(t *testing.T) {
|
||||
received := make(chan string, 1)
|
||||
crossOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer crossOrigin.Close()
|
||||
|
||||
sameOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, crossOrigin.URL, http.StatusFound)
|
||||
}))
|
||||
defer sameOrigin.Close()
|
||||
|
||||
p := &Page{inputURL: mustParseURL(t, sameOrigin.URL)}
|
||||
client := p.authRedirectClient(&http.Client{}, []string{"Authorization", "Bearer secret"})
|
||||
req, err := http.NewRequest(http.MethodGet, sameOrigin.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Empty(t, <-received)
|
||||
}
|
||||
|
||||
// pairsToMap converts the flat [k1,v1,k2,v2,...] header slice into a map.
|
||||
func pairsToMap(t *testing.T, pairs []string) map[string]string {
|
||||
t.Helper()
|
||||
require.Zero(t, len(pairs)%2, "header pairs must be even length")
|
||||
m := make(map[string]string, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
m[pairs[i]] = pairs[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
@@ -43,6 +44,11 @@ type Page struct {
|
||||
variables map[string]interface{}
|
||||
lastActionNavigate *Action
|
||||
cancel context.CancelFunc
|
||||
// authSessionGeneration is the dynamic-session generation applied to this
|
||||
// page, used to ignore late expiry signals from an older session.
|
||||
authSessionGeneration uint64
|
||||
authRefreshPending bool
|
||||
authMutex *sync.RWMutex
|
||||
}
|
||||
|
||||
// HistoryData contains the page request/response pairs
|
||||
@@ -56,6 +62,9 @@ type Options struct {
|
||||
Timeout time.Duration
|
||||
DisableCookie bool
|
||||
Options *types.Options
|
||||
// AuthProvider supplies credentials (headers/cookies) that are injected into
|
||||
// the browser so authenticated headless scans work like HTTP ones. May be nil.
|
||||
AuthProvider authprovider.AuthProvider
|
||||
// OnNavigationResponse receives the response to the initial URL once the
|
||||
// actions ran, even when they failed: the browser fails a navigation on
|
||||
// an error status with an empty body, and that status still matters.
|
||||
@@ -80,12 +89,13 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
|
||||
}
|
||||
page = page.Context(pageContext)
|
||||
createdPage := &Page{
|
||||
options: options,
|
||||
page: page,
|
||||
ctx: ctx,
|
||||
instance: i,
|
||||
mutex: &sync.RWMutex{},
|
||||
cancel: cancelPage,
|
||||
options: options,
|
||||
page: page,
|
||||
ctx: ctx,
|
||||
instance: i,
|
||||
mutex: &sync.RWMutex{},
|
||||
authMutex: &sync.RWMutex{},
|
||||
cancel: cancelPage,
|
||||
}
|
||||
successfulPageCreation := false
|
||||
defer func() {
|
||||
@@ -146,10 +156,17 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
|
||||
createdPage.hijackRouter = hijackRouter
|
||||
go hijackRouter.Run()
|
||||
} else {
|
||||
// Auth headers can only be added before the request is sent, so an
|
||||
// authenticated scan pauses at request stage too. Other scans keep the
|
||||
// single response-stage pause.
|
||||
stage := proto.FetchRequestStageResponse
|
||||
if options.AuthProvider != nil {
|
||||
stage = proto.FetchRequestStageRequest
|
||||
}
|
||||
hijackRouter := NewHijack(page)
|
||||
hijackRouter.SetPattern(&proto.FetchRequestPattern{
|
||||
URLPattern: "*",
|
||||
RequestStage: proto.FetchRequestStageResponse,
|
||||
RequestStage: stage,
|
||||
})
|
||||
createdPage.hijackNative = hijackRouter
|
||||
hijackRouterHandler := hijackRouter.Start(createdPage.routingRuleHandlerNative)
|
||||
@@ -202,6 +219,14 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
|
||||
}
|
||||
}
|
||||
|
||||
// Seed domain-scoped authentication cookies before navigation. Headers are
|
||||
// applied per request by the interception handlers so they cannot leak to a
|
||||
// cross-origin request.
|
||||
createdPage.prepareAuthForNavigation(input)
|
||||
// seed any captured browser web storage (e.g. localStorage JWTs from a
|
||||
// headless auto-login) before page scripts run on navigation.
|
||||
createdPage.applyAuthWebStorage()
|
||||
|
||||
data, err := createdPage.ExecuteActions(ctx, actions)
|
||||
if options.OnNavigationResponse != nil {
|
||||
if resp := createdPage.NavigationResponse(); resp != nil {
|
||||
@@ -234,6 +259,10 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
|
||||
if resp := createdPage.FirstResponse(); resp != nil {
|
||||
data["header"] = utils.HeadersToString(resp.Header)
|
||||
data["status_code"] = fmt.Sprint(resp.StatusCode)
|
||||
// Let any dynamic auth secret inspect the navigation status so an
|
||||
// expired session (reauth-status-codes) is re-authenticated before
|
||||
// the next headless navigation, matching the HTTP protocol.
|
||||
createdPage.notifyAuthResponse(resp.StatusCode)
|
||||
}
|
||||
|
||||
successfulPageCreation = true // avoid closing the page in case of success in deferred function
|
||||
|
||||
@@ -443,6 +443,7 @@ func (p *Page) NavigateURL(action *Action, out ActionData) error {
|
||||
finalparams.Merge(p.inputURL.Params.Encode())
|
||||
parsedURL.Params = finalparams
|
||||
|
||||
p.prepareAuthForNavigation(parsedURL)
|
||||
if err := p.page.Navigate(parsedURL.String()); err != nil {
|
||||
return errkit.Wrapf(err, "could not navigate to url %s", parsedURL.String())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
@@ -17,6 +19,9 @@ func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack)
|
||||
return func(ctx *rod.Hijack) {
|
||||
// usually browsers don't use chunked transfer encoding, so we set the content-length nevertheless
|
||||
ctx.Request.Req().ContentLength = int64(len(ctx.Request.Body()))
|
||||
// Auth headers are request-local and origin-scoped. Apply them before
|
||||
// user rules so explicit set/delete rules retain their existing priority.
|
||||
authHeaders := p.applyAuthHeaders(ctx.Request.Req())
|
||||
// snapshot the rules once: ExecuteActions may still be appending rules
|
||||
// from another goroutine while this hijack handler runs.
|
||||
rules := p.rulesSnapshot()
|
||||
@@ -52,7 +57,7 @@ func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack)
|
||||
}
|
||||
|
||||
// perform the request
|
||||
if err := ctx.LoadResponse(httpClient, true); err != nil {
|
||||
if err := ctx.LoadResponse(p.authRedirectClient(httpClient, authHeaders), true); err != nil {
|
||||
gologger.Verbose().Msgf("headless: failed to load response for %s: %s", ctx.Request.URL(), err)
|
||||
}
|
||||
|
||||
@@ -119,6 +124,9 @@ func (p *Page) routingRuleHandlerNative(e *proto.FetchRequestPaused) error {
|
||||
if err := protocolstate.ValidateNFailRequest(p.options.Options, p.page, e); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.ResponseStatusCode == nil && e.ResponseErrorReason == "" {
|
||||
return p.continueNativeRequest(e)
|
||||
}
|
||||
body, _ := FetchGetResponseBody(p.page, e)
|
||||
headers := make(map[string][]string)
|
||||
for _, h := range e.ResponseHeaders {
|
||||
@@ -158,3 +166,68 @@ func (p *Page) routingRuleHandlerNative(e *proto.FetchRequestPaused) error {
|
||||
|
||||
return FetchContinueRequest(p.page, e)
|
||||
}
|
||||
|
||||
// continueNativeRequest applies origin-scoped auth headers to one browser
|
||||
// request and asks CDP to pause the same request again at response stage so the
|
||||
// existing history capture remains intact.
|
||||
func (p *Page) continueNativeRequest(e *proto.FetchRequestPaused) error {
|
||||
target, err := url.Parse(e.Request.URL)
|
||||
if err != nil {
|
||||
return (&proto.FetchContinueRequest{RequestID: e.RequestID, InterceptResponse: true}).Call(p.page)
|
||||
}
|
||||
authHeaders := p.authHeaders(target)
|
||||
if len(authHeaders) == 0 {
|
||||
return (&proto.FetchContinueRequest{RequestID: e.RequestID, InterceptResponse: true}).Call(p.page)
|
||||
}
|
||||
|
||||
headers := make([]*proto.FetchHeaderEntry, 0, len(e.Request.Headers)+len(authHeaders)/2)
|
||||
for name, value := range e.Request.Headers {
|
||||
if headerPairsContain(authHeaders, name) {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, &proto.FetchHeaderEntry{Name: name, Value: value.Str()})
|
||||
}
|
||||
for i := 0; i+1 < len(authHeaders); i += 2 {
|
||||
headers = append(headers, &proto.FetchHeaderEntry{Name: authHeaders[i], Value: authHeaders[i+1]})
|
||||
}
|
||||
return (&proto.FetchContinueRequest{
|
||||
RequestID: e.RequestID,
|
||||
Headers: headers,
|
||||
InterceptResponse: true,
|
||||
}).Call(p.page)
|
||||
}
|
||||
|
||||
func headerPairsContain(headers []string, name string) bool {
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
if strings.EqualFold(headers[i], name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authRedirectClient preserves the configured client while preventing Go's
|
||||
// internal redirect handling from forwarding auth-derived headers across
|
||||
// origins, where browser request interception cannot observe the redirect hop.
|
||||
func (p *Page) authRedirectClient(client *http.Client, authHeaders []string) *http.Client {
|
||||
if client == nil || len(authHeaders) == 0 {
|
||||
return client
|
||||
}
|
||||
cloned := *client
|
||||
previousCheck := client.CheckRedirect
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if !p.authOriginMatches(req.URL) {
|
||||
for i := 0; i+1 < len(authHeaders); i += 2 {
|
||||
req.Header.Del(authHeaders[i])
|
||||
}
|
||||
}
|
||||
if previousCheck != nil {
|
||||
return previousCheck(req, via)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
@@ -147,6 +147,7 @@ func (request *Request) executeRequestWithPayloads(input *contextargs.Context, p
|
||||
Timeout: pageTimeout,
|
||||
DisableCookie: request.DisableCookie,
|
||||
Options: request.options.Options,
|
||||
AuthProvider: request.options.AuthProvider,
|
||||
// only the navigation's status speaks for the host: a failed step or
|
||||
// a subresource from another origin does not
|
||||
OnNavigationResponse: func(resp *http.Response) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/expressions"
|
||||
@@ -142,6 +143,42 @@ func (g *generatedRequest) ApplyAuth(provider authprovider.AuthProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyResponse forwards the response status code to any auth strategies that
|
||||
// inspect responses (e.g. dynamic secrets), so an expired session can be
|
||||
// detected and re-authenticated before the next request.
|
||||
func (g *generatedRequest) NotifyResponse(provider authprovider.AuthProvider, resp *http.Response) {
|
||||
if provider == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
var target *urlutil.URL
|
||||
switch {
|
||||
case g.request != nil:
|
||||
target = g.request.URL
|
||||
case g.rawRequest != nil:
|
||||
parsed, err := urlutil.ParseAbsoluteURL(g.rawRequest.FullURL, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
target = parsed
|
||||
}
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
for _, strategy := range provider.LookupURLX(target) {
|
||||
if inspector, ok := strategy.(authx.ResponseInspector); ok {
|
||||
var gen uint64
|
||||
if g.request != nil && g.request.Request != nil {
|
||||
gen = authx.SessionGenerationFromRequest(g.request.Request)
|
||||
} else if resp.Request != nil {
|
||||
gen = authx.SessionGenerationFromRequest(resp.Request)
|
||||
}
|
||||
if inspector.OnResponse(resp.StatusCode, gen) {
|
||||
gologger.Verbose().Msgf("[authprovider] Session expired (status %d) for %s, will re-authenticate\n", resp.StatusCode, target.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *generatedRequest) URL() string {
|
||||
if g.request != nil {
|
||||
return g.request.String()
|
||||
|
||||
@@ -386,3 +386,4 @@ func (s staticReplayAuth) LookupURLX(*urlutil.URL) []authx.AuthStrategy {
|
||||
}
|
||||
func (s staticReplayAuth) GetTemplatePaths() []string { return nil }
|
||||
func (s staticReplayAuth) PreFetchSecrets() error { return nil }
|
||||
func (s staticReplayAuth) Close() {}
|
||||
@@ -1434,6 +1434,13 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
|
||||
return err
|
||||
}
|
||||
|
||||
// === detect session expiry and schedule re-authentication ===
|
||||
// If a dynamic auth secret was applied, let it inspect the response status so
|
||||
// an expired session is re-authenticated before subsequent requests.
|
||||
if generatedRequest.request != nil && !request.SkipSecretFile && resp != nil {
|
||||
generatedRequest.NotifyResponse(request.options.AuthProvider, resp)
|
||||
}
|
||||
|
||||
var curlCommand string
|
||||
if !request.Unsafe && resp != nil && generatedRequest.request != nil && resp.Request != nil && !request.Race {
|
||||
body := generatedRequest.body()
|
||||
|
||||
@@ -303,7 +303,7 @@ func newCookieAuthProvider(t *testing.T, authType, domain, cookieName string) au
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(secretFile, []byte(secret), 0o600))
|
||||
provider, err := authprovider.NewFileAuthProvider(secretFile, fetchSecret)
|
||||
provider, err := authprovider.NewFileAuthProvider(secretFile, fetchSecret, nil)
|
||||
require.NoError(t, err)
|
||||
return provider
|
||||
}
|
||||
|
||||
@@ -490,6 +490,35 @@ type Options struct {
|
||||
SecretsFile goflags.StringSlice
|
||||
// PreFetchSecrets pre-fetches the secrets from the auth provider
|
||||
PreFetchSecrets bool
|
||||
// AuthLoginURL is the login page URL for turnkey auto-login (template-free
|
||||
// authenticated scanning). When set, nuclei detects the login form, submits
|
||||
// the credentials and applies the captured session to the scan.
|
||||
AuthLoginURL string
|
||||
// AuthUsername / AuthPassword are the credentials submitted to AuthLoginURL.
|
||||
AuthUsername string
|
||||
AuthPassword string
|
||||
// AuthUsernameField / AuthPasswordField optionally override the detected
|
||||
// login form field names.
|
||||
AuthUsernameField string
|
||||
AuthPasswordField string
|
||||
// AuthHeadless drives the auto-login through a real browser (for JS-rendered
|
||||
// / SPA / multi-step login pages).
|
||||
AuthHeadless bool
|
||||
// AuthRecording is a path to a Chrome DevTools Recorder / @puppeteer/replay
|
||||
// JSON export of a login, replayed headless to acquire the session (implies
|
||||
// a headless auto-login).
|
||||
AuthRecording string
|
||||
// AuthCapture launches a visible browser at -auth-login-url for a one-time
|
||||
// manual login; the resulting session (cookies/token) is captured and used
|
||||
// for the scan. There is no automated re-authentication.
|
||||
AuthCapture bool
|
||||
// AuthReauthStatusCodes is a comma-separated list of HTTP status codes that
|
||||
// signal an expired session (e.g. "401,403"); when a scan response matches,
|
||||
// the auto-login is re-run before the next request. Empty disables it.
|
||||
AuthReauthStatusCodes string
|
||||
// AuthRefreshInterval, when set (e.g. "15m"), proactively re-runs the
|
||||
// auto-login once the captured session is older than the interval.
|
||||
AuthRefreshInterval string
|
||||
// FormatUseRequiredOnly only uses required fields when generating requests
|
||||
FormatUseRequiredOnly bool
|
||||
// SkipFormatValidation is used to skip format validation
|
||||
|
||||
Reference in new issue
Block a user