alot more changes
This commit is contained in:
1 parent
23243ddbdc
commit
0ed2935f04
40 files changed
+2889
-115
No files matched your search
@@ -0,0 +1,192 @@
|
||||
// Package abuseipdb proxies the AbuseIPDB API for authenticated operator actions.
|
||||
// The API key is deliberately kept on the server; browsers only receive the
|
||||
// selected IP's response data.
|
||||
package abuseipdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.abuseipdb.com/api/v2"
|
||||
|
||||
type Config struct {
|
||||
Key string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func ConfigFromEnv() Config {
|
||||
base := strings.TrimRight(os.Getenv("ABUSE_API_URL"), "/")
|
||||
if base == "" {
|
||||
base = defaultBaseURL
|
||||
}
|
||||
return Config{Key: strings.TrimSpace(os.Getenv("ABUSE_KEY")), BaseURL: base}
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHandler(cfg Config) *Handler {
|
||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
ReportedAt string `json:"reportedAt,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Categories []int `json:"categories,omitempty"`
|
||||
ReporterCountryCode string `json:"reporterCountryCode,omitempty"`
|
||||
}
|
||||
|
||||
type CheckData struct {
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
IPVersion int `json:"ipVersion,omitempty"`
|
||||
IsWhitelisted bool `json:"isWhitelisted"`
|
||||
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
|
||||
CountryCode string `json:"countryCode,omitempty"`
|
||||
UsageType string `json:"usageType,omitempty"`
|
||||
ISP string `json:"isp,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
TotalReports int `json:"totalReports"`
|
||||
NumDistinctUsers int `json:"numDistinctUsers"`
|
||||
LastReportedAt string `json:"lastReportedAt,omitempty"`
|
||||
Reports []Report `json:"reports,omitempty"`
|
||||
}
|
||||
|
||||
type checkResponse struct {
|
||||
Data CheckData `json:"data"`
|
||||
}
|
||||
|
||||
type reportResponse struct {
|
||||
Data struct {
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
ip, ok := h.publicIP(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := url.Values{"ipAddress": {ip.String()}, "maxAgeInDays": {"90"}}
|
||||
var out checkResponse
|
||||
if err := h.request(r.Context(), http.MethodGet, "/check?"+query.Encode(), nil, &out); err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, out.Data)
|
||||
}
|
||||
|
||||
type reportRequest struct {
|
||||
Categories []int `json:"categories"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func (h *Handler) Report(w http.ResponseWriter, r *http.Request) {
|
||||
ip, ok := h.publicIP(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req reportRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid report request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Categories) == 0 {
|
||||
http.Error(w, "at least one category is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Comment) > 1000 {
|
||||
http.Error(w, "comment is too long", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
form := url.Values{"ip": {ip.String()}, "comment": {strings.TrimSpace(req.Comment)}}
|
||||
categories := make([]string, len(req.Categories))
|
||||
for i, category := range req.Categories {
|
||||
if category < 1 || category > 30 {
|
||||
http.Error(w, "category must be between 1 and 30", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
categories[i] = strconv.Itoa(category)
|
||||
}
|
||||
form.Set("categories", strings.Join(categories, ","))
|
||||
var out reportResponse
|
||||
if err := h.request(r.Context(), http.MethodPost, "/report", strings.NewReader(form.Encode()), &out); err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, out.Data)
|
||||
}
|
||||
|
||||
func (h *Handler) publicIP(w http.ResponseWriter, r *http.Request) (netip.Addr, bool) {
|
||||
if h.cfg.Key == "" {
|
||||
http.Error(w, "AbuseIPDB is not configured (set ABUSE_KEY)", http.StatusNotImplemented)
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(r.PathValue("ip")))
|
||||
if err != nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() {
|
||||
http.Error(w, "only public unicast IPs can be checked or reported", http.StatusBadRequest)
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
|
||||
func (h *Handler) request(ctx context.Context, method, path string, body io.Reader, dst any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, h.cfg.BaseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Key", h.cfg.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var apiErr struct {
|
||||
Errors []struct {
|
||||
Detail string `json:"detail"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&apiErr)
|
||||
if len(apiErr.Errors) > 0 && apiErr.Errors[0].Detail != "" {
|
||||
return fmt.Errorf("AbuseIPDB: %s", apiErr.Errors[0].Detail)
|
||||
}
|
||||
return fmt.Errorf("AbuseIPDB returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(dst); err != nil {
|
||||
return fmt.Errorf("invalid AbuseIPDB response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status = http.StatusGatewayTimeout
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package abuseipdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckAndReport(t *testing.T) {
|
||||
var gotKey string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey = r.Header.Get("Key")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/check" {
|
||||
if r.URL.Query().Get("ipAddress") != "8.8.8.8" || r.URL.Query().Get("maxAgeInDays") != "90" {
|
||||
t.Fatalf("unexpected check query: %s", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"ipAddress":"8.8.8.8","abuseConfidenceScore":75,"totalReports":4}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/report" || r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected report request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || r.Form.Get("ip") != "8.8.8.8" || r.Form.Get("categories") != "14,18" {
|
||||
t.Fatalf("unexpected report form: %v", r.Form)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"ipAddress":"8.8.8.8","abuseConfidenceScore":80}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
h := NewHandler(Config{Key: "test-key", BaseURL: server.URL})
|
||||
|
||||
check := httptest.NewRecorder()
|
||||
checkReq := httptest.NewRequest(http.MethodGet, "/api/source-ips/8.8.8.8/abuseipdb", nil).WithContext(t.Context())
|
||||
checkReq.SetPathValue("ip", "8.8.8.8")
|
||||
h.Check(check, checkReq)
|
||||
if check.Code != http.StatusOK || !strings.Contains(check.Body.String(), `"abuseConfidenceScore":75`) {
|
||||
t.Fatalf("check response: %d %s", check.Code, check.Body.String())
|
||||
}
|
||||
|
||||
report := httptest.NewRecorder()
|
||||
body, _ := json.Marshal(reportRequest{Categories: []int{14, 18}, Comment: "scanner activity"})
|
||||
reportReq := httptest.NewRequest(http.MethodPost, "/api/source-ips/8.8.8.8/abuseipdb/report", strings.NewReader(string(body))).WithContext(t.Context())
|
||||
reportReq.SetPathValue("ip", "8.8.8.8")
|
||||
h.Report(report, reportReq)
|
||||
if report.Code != http.StatusOK || !strings.Contains(report.Body.String(), `"abuseConfidenceScore":80`) {
|
||||
t.Fatalf("report response: %d %s", report.Code, report.Body.String())
|
||||
}
|
||||
if gotKey != "test-key" {
|
||||
t.Fatalf("key header = %q", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsPrivateAndUnconfigured(t *testing.T) {
|
||||
private := httptest.NewRecorder()
|
||||
privateReq := httptest.NewRequest(http.MethodGet, "/api/source-ips/192.168.1.2/abuseipdb", nil)
|
||||
privateReq.SetPathValue("ip", "192.168.1.2")
|
||||
NewHandler(Config{Key: "test-key", BaseURL: "http://invalid"}).Check(private, privateReq)
|
||||
if private.Code != http.StatusBadRequest {
|
||||
t.Fatalf("private status = %d", private.Code)
|
||||
}
|
||||
unconfigured := httptest.NewRecorder()
|
||||
unconfiguredReq := httptest.NewRequest(http.MethodGet, "/api/source-ips/8.8.8.8/abuseipdb", nil)
|
||||
unconfiguredReq.SetPathValue("ip", "8.8.8.8")
|
||||
NewHandler(Config{}).Check(unconfigured, unconfiguredReq)
|
||||
if unconfigured.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("unconfigured status = %d", unconfigured.Code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user