mirror of
https://gitgud.io/yats/cerberus.git
synced 2026-04-30 09:42:06 -04:00
181 lines
4.1 KiB
Go
181 lines
4.1 KiB
Go
package cerberus
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
var (
|
|
ErrNoRedirect = errors.New("No redirect to challenge page.")
|
|
ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.")
|
|
)
|
|
|
|
type ErrInvalidSolution struct {
|
|
s Solution
|
|
}
|
|
|
|
func (e *ErrInvalidSolution) Error() string {
|
|
return fmt.Sprintf("Received 400 status when submitting solution: %+v", e.s)
|
|
}
|
|
|
|
// Request new Tartarus challenge from provided host.
|
|
func NewChallenge(ctx context.Context, hc http.Client, host string) (Challenge, error) {
|
|
u, err := parseHost(host)
|
|
if err != nil {
|
|
return Challenge{}, err
|
|
}
|
|
|
|
// Update host url in case we get redirected across domains.
|
|
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
rh := req.URL.Host
|
|
if rh != u.Host && strings.HasPrefix(rh, "kiwifarms") {
|
|
u.Host = rh
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
if err != nil {
|
|
return Challenge{}, err
|
|
}
|
|
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return Challenge{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check for 203 status. A 203 indicates a redirect to a challenge page.
|
|
if resp.StatusCode != 203 {
|
|
return Challenge{}, ErrNoRedirect
|
|
}
|
|
|
|
// Kept separate from the return because of the defer.
|
|
c, err := parseTags(resp.Body)
|
|
if err != nil {
|
|
return Challenge{}, err
|
|
}
|
|
c.host = u
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// Submit a Solution for a Challenge.
|
|
//
|
|
// If redirect is empty, "/" is used as a sensible default.
|
|
// Auth cookies get set automatically in http.Client's CookieJar.
|
|
// The *http.Response is provided to support more advanced setups. The auth token can also be found in its Body.
|
|
func Submit(ctx context.Context, hc http.Client, s Solution, redirect string) (*http.Response, error) {
|
|
if redirect != "" {
|
|
s.Redirect = redirect
|
|
}
|
|
|
|
resp, err := postSolution(ctx, hc, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// This feels gross, but it works.
|
|
for s.Steps > 0 {
|
|
resp.Body.Close()
|
|
|
|
c, err := NewChallenge(ctx, hc, s.host.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s, err = Solve(ctx, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = Submit(ctx, hc, s, redirect)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func parseHost(addr string) (*url.URL, error) {
|
|
// Guess https as protocol if one wasn't provided and hope it parses.
|
|
if !strings.Contains(addr, "://") {
|
|
addr = "https://" + addr
|
|
}
|
|
|
|
return url.Parse(addr)
|
|
}
|
|
|
|
func postSolution(ctx context.Context, hc http.Client, s Solution) (*http.Response, error) {
|
|
// Ensure the POST url parses properly before passing the string.
|
|
u, err := url.Parse(fmt.Sprintf("%s://%s/.ttrs/challenge", s.host.Scheme, s.host.Hostname()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reqBody := strings.NewReader(fmt.Sprintf(`salt=%s&redirect=%s&nonce=%d`, s.Salt, url.PathEscape(s.Redirect), s.Nonce))
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: Additionally verify failure from response JSON. Maybe include resp body in err type.
|
|
// Rejected solution response: status=400 body={"success":false,"reason":"invalid_solution","action":"retry"}
|
|
if resp.StatusCode == 400 {
|
|
defer resp.Body.Close()
|
|
return nil, &ErrInvalidSolution{s}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func parseTags(r io.Reader) (Challenge, error) {
|
|
c := Challenge{}
|
|
|
|
z := html.NewTokenizer(r)
|
|
for i := z.Next(); i != html.ErrorToken; i = z.Next() {
|
|
tk := z.Token()
|
|
if tk.DataAtom == atom.Html {
|
|
for _, a := range tk.Attr {
|
|
switch a.Key {
|
|
case "data-ttrs-challenge":
|
|
c.Salt = a.Val
|
|
case "data-ttrs-difficulty":
|
|
diff, err := strconv.Atoi(a.Val)
|
|
if err != nil {
|
|
return c, ErrParseFailed
|
|
}
|
|
c.Diff = uint32(diff)
|
|
case "data-ttrs-steps":
|
|
steps, err := strconv.Atoi(a.Val)
|
|
if err != nil {
|
|
return c, ErrParseFailed
|
|
}
|
|
c.Steps = int8(steps)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if c.Salt == "" {
|
|
return c, ErrParseFailed
|
|
}
|
|
|
|
return c, nil
|
|
}
|