fix: add default timeout and handle errors for HaveIBeenPwned API (#39316)

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
afishcalledwander
2026-09-16 14:41:57 -04:00
committed by GitHub
parent 7ebb2caa9e
commit b2e11ddb37
9 changed files with 74 additions and 96 deletions
+1 -2
View File
@@ -38,8 +38,7 @@ func IsPwned(ctx context.Context, password string) error {
return nil
}
client := pwn.New(pwn.WithContext(ctx))
count, err := client.CheckPassword(password, true)
count, err := pwn.New().CheckPassword(ctx, password, true)
if err != nil {
return ErrIsPwnedRequest{err}
}
+27 -58
View File
@@ -14,67 +14,31 @@ import (
"strconv"
"strings"
"gitea.dev/modules/httplib"
"gitea.dev/modules/setting"
)
const passwordURL = "https://api.pwnedpasswords.com/range/"
// ErrEmptyPassword is an empty password error
var ErrEmptyPassword = errors.New("password cannot be empty")
const (
passwordURL = "https://api.pwnedpasswords.com/range/"
maxResponseSize = 1 << 20
)
// Client is a HaveIBeenPwned client
type Client struct {
ctx context.Context
http *http.Client
mockTransport http.RoundTripper
}
// New returns a new HaveIBeenPwned Client
func New(options ...ClientOption) *Client {
client := &Client{
ctx: context.Background(),
http: http.DefaultClient,
}
for _, opt := range options {
opt(client)
}
return client
}
// ClientOption is a way to modify a new Client
type ClientOption func(*Client)
// WithHTTP will set the http.Client of a Client
func WithHTTP(httpClient *http.Client) func(pwnClient *Client) {
return func(pwnClient *Client) {
pwnClient.http = httpClient
}
}
// WithContext will set the context.Context of a Client
func WithContext(ctx context.Context) func(pwnClient *Client) {
return func(pwnClient *Client) {
pwnClient.ctx = ctx
}
}
func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", "Gitea "+setting.AppVer)
return req, nil
func New() *Client {
return &Client{}
}
// CheckPassword returns the number of times a password has been compromised
// Adding padding will make requests more secure, however is also slower
// because artificial responses will be added to the response
// For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
func (c *Client) CheckPassword(pw string, padding bool) (int64, error) {
func (c *Client) CheckPassword(ctx context.Context, pw string, padding bool) (int64, error) {
if pw == "" {
return -1, ErrEmptyPassword
return -1, errors.New("password cannot be empty")
}
sha := sha1.New()
@@ -82,25 +46,30 @@ func (c *Client) CheckPassword(pw string, padding bool) (int64, error) {
enc := hex.EncodeToString(sha.Sum(nil))
prefix, suffix := enc[:5], enc[5:]
req, err := newRequest(c.ctx, http.MethodGet, fmt.Sprintf("%s%s", passwordURL, prefix), nil)
if err != nil {
return -1, nil
}
req := httplib.NewClientRequest(http.MethodGet, fmt.Sprintf("%s%s", passwordURL, prefix))
req.SetContext(ctx).SetTransport(c.mockTransport)
req.Header("User-Agent", "Gitea "+setting.AppVer)
if padding {
req.Header.Add("Add-Padding", "true")
req.Header("Add-Padding", "true")
}
resp, err := c.http.Do(req)
if err != nil {
return -1, err
}
body, err := io.ReadAll(resp.Body)
resp, err := req.Response()
if err != nil {
return -1, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return -1, fmt.Errorf("unexpected status code %d from HaveIBeenPwned API", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return -1, err
}
if len(body) > maxResponseSize {
return -1, fmt.Errorf("response from HaveIBeenPwned API exceeds %d bytes", maxResponseSize)
}
for pair := range strings.SplitSeq(string(body), "\n") {
parts := strings.Split(pair, ":")
if len(parts) != 2 {
+25 -9
View File
@@ -26,36 +26,52 @@ func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
"/range/5617b": "FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0",
"/range/79082": "FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0",
}
if req.URL.Path == "/range/b6b47" { // sha1("ratelimited") prefix
return &http.Response{Request: req, StatusCode: http.StatusTooManyRequests, Body: io.NopCloser(strings.NewReader("rate limited"))}, nil
}
if req.URL.Path == "/range/76eff" {
return &http.Response{Request: req, StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(strings.Repeat("0", maxResponseSize+1)))}, nil
}
if resp, ok := respMap[req.URL.Path]; ok {
return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(resp))}, nil
return &http.Response{Request: req, StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(resp))}, nil
}
return nil, errors.New("unsupported path")
}
func TestPassword(t *testing.T) {
client := New(WithHTTP(&http.Client{Transport: mockTransport{}}))
ctx := t.Context()
client := New()
client.mockTransport = mockTransport{}
count, err := client.CheckPassword("", false)
assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword")
count, err := client.CheckPassword(ctx, "", false)
assert.ErrorContains(t, err, "password cannot be empty")
assert.EqualValues(t, -1, count)
count, err = client.CheckPassword("pwned", false)
count, err = client.CheckPassword(ctx, "pwned", false)
assert.NoError(t, err)
assert.EqualValues(t, 1, count)
count, err = client.CheckPassword("notpwned", false)
count, err = client.CheckPassword(ctx, "notpwned", false)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
count, err = client.CheckPassword("paddedpwned", true)
count, err = client.CheckPassword(ctx, "paddedpwned", true)
assert.NoError(t, err)
assert.EqualValues(t, 1, count)
count, err = client.CheckPassword("paddednotpwned", true)
count, err = client.CheckPassword(ctx, "paddednotpwned", true)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
count, err = client.CheckPassword("paddednotpwnedzero", true)
count, err = client.CheckPassword(ctx, "paddednotpwnedzero", true)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
count, err = client.CheckPassword(ctx, "ratelimited", false)
assert.Error(t, err)
assert.EqualValues(t, -1, count)
count, err = client.CheckPassword(ctx, "oversized", false)
assert.ErrorContains(t, err, "exceeds")
assert.EqualValues(t, -1, count)
}