mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-17 05:52:13 +09:00
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:
committed by
GitHub
parent
7ebb2caa9e
commit
b2e11ddb37
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user