package fetch import ( "bytes" "context" "crypto/sha256" "encoding/json" "fmt" "io" "net/http" neturl "net/url" "strconv" "strings" "time" ) // Some openSUSE hosts (code.opensuse.org, and the web routes of src.opensuse.org) answer every // request with a 403 page until the client pays a small proof-of-work, as their page script // does in a browser: fetch a challenge {t, r, s} from powPath; for t=1 find the smallest n // such that hex(sha256(r+n)) starts with "0000", for t=0 wait three seconds; POST "r-s-n" // back. The answer sets a cookie (for the whole opensuse.org domain) that admits later // requests, so the cost is paid about once per process. // powPath is the challenge endpoint; its presence in a 403 body identifies the check. const powPath = "/cdn-cgi/challenge-platform/challenge" // powMaxTries bounds the search; the expected cost of a 16-bit prefix is 65536 hashes. const powMaxTries = 1 << 26 func isPoWChallenge(status int, body []byte) bool { return status == http.StatusForbidden && bytes.Contains(body, []byte(powPath)) } // solvePoWNonce returns the smallest n such that sha256(r+n) begins with two zero bytes // (the hex prefix "0000"). func solvePoWNonce(r string) (int, bool) { for n := 0; n < powMaxTries; n++ { sum := sha256.Sum256([]byte(r + strconv.Itoa(n))) if sum[0] == 0 && sum[1] == 0 { return n, true } } return 0, false } // payPoW answers the challenge of origin (scheme://host). The cookie lands in c.HTTP.Jar. func (c *Client) payPoW(ctx context.Context, origin string) error { c.powMu.Lock() defer c.powMu.Unlock() if c.HTTP.Jar == nil { return fmt.Errorf("no cookie jar to keep the answer in") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+powPath, nil) if err != nil { return err } req.Header.Set("User-Agent", c.UserAgent) resp, err := c.HTTP.Do(req) if err != nil { return err } b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() if err != nil { return err } var ch struct { T int `json:"t"` R string `json:"r"` S string `json:"s"` } if err := json.Unmarshal(b, &ch); err != nil { return fmt.Errorf("challenge: %w", err) } n := 0 switch ch.T { case 0: select { case <-ctx.Done(): return ctx.Err() case <-time.After(3 * time.Second): } case 1: var ok bool if n, ok = solvePoWNonce(ch.R); !ok { return fmt.Errorf("no nonce within %d tries", powMaxTries) } default: return fmt.Errorf("unknown challenge type %d", ch.T) } answer := ch.R + "-" + ch.S + "-" + strconv.Itoa(n) req, err = http.NewRequestWithContext(ctx, http.MethodPost, origin+powPath, strings.NewReader(answer)) if err != nil { return err } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Content-Type", "text/plain") resp, err = c.HTTP.Do(req) if err != nil { return err } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("answer rejected: HTTP %d", resp.StatusCode) } c.logf("fetch: paid the proof-of-work challenge of %s (%d hashes)", origin, n+1) return nil } func origin(rawURL string) string { u, err := neturl.Parse(rawURL) if err != nil { return "" } return u.Scheme + "://" + u.Host }