3 Commits

10 changed files with 223 additions and 451 deletions

View File

@ -47,7 +47,7 @@ curl -6 ifconfig.es
## Endpoints
- https://ifconfig.es/
- https://ifconfig.es/json
- https://ifconfig.es/json (this is the same as `curl -H "Accept: application/json" https://ifconfig.es/`)
- https://ifconfig.es/geo
- https://ifconfig.es/geo/city
- https://ifconfig.es/geo/country

View File

@ -17,6 +17,7 @@ import (
"github.com/dcarrillo/whatismyip/router"
"github.com/gin-gonic/gin"
"github.com/unrolled/secure"
)
var (
@ -138,6 +139,28 @@ func setupEngine() {
engine = gin.New()
engine.Use(gin.LoggerWithFormatter(httputils.GetLogFormatter))
engine.Use(gin.Recovery())
if setting.App.EnableSecureHeaders {
engine.Use(addSecureHeaders())
}
_ = engine.SetTrustedProxies(nil)
engine.TrustedPlatform = setting.App.TrustedHeader
}
func addSecureHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
err := secure.New(secure.Options{
BrowserXssFilter: true,
ContentTypeNosniff: true,
FrameDeny: true,
}).Process(c.Writer, c.Request)
if err != nil {
c.Abort()
return
}
// Avoid header rewrite if response is a redirection.
if status := c.Writer.Status(); status > 300 && status < 399 {
c.Abort()
}
}
}

19
go.mod
View File

@ -4,9 +4,10 @@ go 1.18
require (
github.com/gin-gonic/gin v1.7.7
github.com/oschwald/maxminddb-golang v1.8.0
github.com/oschwald/maxminddb-golang v1.9.0
github.com/stretchr/testify v1.7.1
github.com/testcontainers/testcontainers-go v0.12.0
github.com/unrolled/secure v1.10.0
)
require (
@ -15,10 +16,10 @@ require (
github.com/Microsoft/hcsshim v0.9.2 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/containerd/cgroups v1.0.3 // indirect
github.com/containerd/containerd v1.6.1 // indirect
github.com/containerd/containerd v1.6.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/docker v20.10.13+incompatible // indirect
github.com/docker/docker v20.10.14+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@ -41,19 +42,19 @@ require (
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/opencontainers/runc v1.1.0 // indirect
github.com/opencontainers/runc v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
golang.org/x/net v0.0.0-20220401154927-543a649e0bdd // indirect
golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220317150908-0efb43f6373e // indirect
google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

371
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ func buildContainer() testcontainers.ContainerRequest {
"-tls-crt", "/tmp/server.pem",
"-tls-key", "/tmp/server.key",
"-trusted-header", "X-Real-IP",
"-enable-secure-headers",
},
ExposedPorts: []string{"8000:8000", "8001:8001"},
WaitingFor: wait.ForLog("Starting TLS server listening on :8001"),
@ -69,16 +70,18 @@ func TestContainerIntegration(t *testing.T) {
}()
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
for _, url := range []string{"http://localhost:8000/json", "https://localhost:8001/json"} {
resp, _ := http.Get(url)
for _, url := range []string{"http://localhost:8000", "https://localhost:8001"} {
client := &http.Client{}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Accept", "application/json")
resp, _ := client.Do(req)
assert.Equal(t, 200, resp.StatusCode)
var dat router.JSONResponse
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
assert.NoError(t, json.Unmarshal(body, &dat))
assert.NoError(t, json.Unmarshal(body, &router.JSONResponse{}))
}
}

View File

@ -20,15 +20,16 @@ type serverSettings struct {
WriteTimeout time.Duration
}
type settings struct {
GeodbPath geodbPath
TemplatePath string
BindAddress string
TLSAddress string
TLSCrtPath string
TLSKeyPath string
TrustedHeader string
Server serverSettings
version bool
GeodbPath geodbPath
TemplatePath string
BindAddress string
TLSAddress string
TLSCrtPath string
TLSKeyPath string
TrustedHeader string
EnableSecureHeaders bool
Server serverSettings
version bool
}
const defaultAddress = ":8080"
@ -74,6 +75,12 @@ func Setup(args []string) (output string, err error) {
"Trusted request header for remote IP (e.g. X-Real-IP)",
)
flags.BoolVar(&App.version, "version", false, "Output version information and exit")
flags.BoolVar(
&App.EnableSecureHeaders,
"enable-secure-headers",
false,
"Add sane security-related headers to every response",
)
err = flags.Parse(args)
if err != nil {

View File

@ -70,12 +70,13 @@ func TestParseFlags(t *testing.T) {
City: "/city-path",
ASN: "/asn-path",
},
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "",
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "",
EnableSecureHeaders: false,
Server: serverSettings{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@ -89,12 +90,13 @@ func TestParseFlags(t *testing.T) {
City: "/city-path",
ASN: "/asn-path",
},
TemplatePath: "",
BindAddress: ":8001",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "",
TemplatePath: "",
BindAddress: ":8001",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "",
EnableSecureHeaders: false,
Server: serverSettings{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@ -111,12 +113,13 @@ func TestParseFlags(t *testing.T) {
City: "/city-path",
ASN: "/asn-path",
},
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: ":9000",
TLSCrtPath: "/crt-path",
TLSKeyPath: "/key-path",
TrustedHeader: "",
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: ":9000",
TLSCrtPath: "/crt-path",
TLSKeyPath: "/key-path",
TrustedHeader: "",
EnableSecureHeaders: false,
Server: serverSettings{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@ -126,19 +129,20 @@ func TestParseFlags(t *testing.T) {
{
[]string{
"-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path",
"-trusted-header", "header",
"-trusted-header", "header", "-enable-secure-headers",
},
settings{
GeodbPath: geodbPath{
City: "/city-path",
ASN: "/asn-path",
},
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "header",
TemplatePath: "",
BindAddress: ":8080",
TLSAddress: "",
TLSCrtPath: "",
TLSKeyPath: "",
TrustedHeader: "header",
EnableSecureHeaders: true,
Server: serverSettings{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,

View File

@ -4,7 +4,6 @@ import (
"net"
"net/http"
"path/filepath"
"regexp"
"github.com/dcarrillo/whatismyip/internal/httputils"
"github.com/dcarrillo/whatismyip/internal/setting"
@ -12,8 +11,6 @@ import (
"github.com/gin-gonic/gin"
)
const userAgentPattern = `curl|wget|libwww-perl|python|ansible-httpget|HTTPie|WindowsPowerShell|http_request|Go-http-client|^$`
// JSONResponse maps data as json
type JSONResponse struct {
IP string `json:"ip"`
@ -33,15 +30,17 @@ type JSONResponse struct {
}
func getRoot(ctx *gin.Context) {
reg := regexp.MustCompile(userAgentPattern)
if reg.Match([]byte(ctx.Request.UserAgent())) {
ctx.String(http.StatusOK, ctx.ClientIP())
} else {
switch ctx.NegotiateFormat(gin.MIMEPlain, gin.MIMEHTML, gin.MIMEJSON) {
case gin.MIMEHTML:
name := "home"
if setting.App.TemplatePath != "" {
name = filepath.Base(setting.App.TemplatePath)
}
ctx.HTML(http.StatusOK, name, jsonOutput(ctx))
case gin.MIMEJSON:
getJSON(ctx)
default:
ctx.String(http.StatusOK, ctx.ClientIP()+"\n")
}
}

View File

@ -9,31 +9,80 @@ import (
"github.com/stretchr/testify/assert"
)
func TestIP4RootFromCli(t *testing.T) {
uas := []string{
"",
"curl",
"wget",
"libwww-perl",
"python",
"ansible-httpget",
"HTTPie",
"WindowsPowerShell",
"http_request",
"Go-http-client",
func TestRootContentType(t *testing.T) {
tests := []struct {
name string
accepted string
expected string
}{
{
name: "Accept wildcard",
accepted: "*/*",
expected: contentType.text,
},
{
name: "Bogus accept",
accepted: "bogus/plain",
expected: contentType.text,
},
{
name: "Accept plain text",
accepted: "text/plain",
expected: contentType.text,
},
{
name: "Accept json",
accepted: "application/json",
expected: contentType.json,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set(trustedHeader, testIP.ipv4)
req.Header.Set("Accept", tt.accepted)
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", testIP.ipv4)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
for _, ua := range uas {
req.Header.Set("User-Agent", ua)
assert.Equal(t, 200, w.Code)
assert.Equal(t, tt.expected, w.Header().Get("Content-Type"))
})
}
}
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
func TestGetIP(t *testing.T) {
expected := testIP.ipv4 + "\n"
tests := []struct {
name string
accepted string
}{
{
name: "No browser",
accepted: "*/*",
},
{
name: "Bogus accept",
accepted: "bogus/plain",
},
{
name: "Plain accept",
accepted: "text/plain",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set(trustedHeader, testIP.ipv4)
req.Header.Set("Accept", tt.accepted)
assert.Equal(t, 200, w.Code)
assert.Equal(t, testIP.ipv4, w.Body.String())
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, expected, w.Body.String())
assert.Equal(t, contentType.text, w.Header().Get("Content-Type"))
})
}
}
@ -50,7 +99,7 @@ func TestHost(t *testing.T) {
func TestClientPort(t *testing.T) {
req, _ := http.NewRequest("GET", "/client-port", nil)
req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000")
req.Header.Set("X-Real-IP", testIP.ipv4)
req.Header.Set(trustedHeader, testIP.ipv4)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
@ -71,31 +120,44 @@ func TestNotFound(t *testing.T) {
}
func TestJSON(t *testing.T) {
expectedIPv4 := `{"client_port":"1000","ip":"81.2.69.192","ip_version":4,"country":"United Kingdom","country_code":"GB","city":"London","latitude":51.5142,"longitude":-0.0931,"postal_code":"","time_zone":"Europe/London","asn":0,"asn_organization":"","host":"test","headers":{"X-Real-Ip":["81.2.69.192"]}}`
expectedIPv6 := `{"asn":3352, "asn_organization":"TELEFONICA DE ESPANA", "city":"", "client_port":"1000", "country":"", "country_code":"", "headers":{"X-Real-Ip":["2a02:9000::1"]}, "host":"test", "ip":"2a02:9000::1", "ip_version":6, "latitude":0, "longitude":0, "postal_code":"", "time_zone":""}`
type args struct {
ip string
}
tests := []struct {
name string
args args
expected string
}{
{
name: "IPv4",
args: args{
ip: testIP.ipv4,
},
expected: jsonIPv4,
},
{
name: "IPv6",
args: args{
ip: testIP.ipv6,
},
expected: jsonIPv6,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "/json", nil)
req.RemoteAddr = net.JoinHostPort(tt.args.ip, "1000")
req.Host = "test"
req.Header.Set(trustedHeader, tt.args.ip)
req, _ := http.NewRequest("GET", "/json", nil)
req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000")
req.Host = "test"
req.Header.Set("X-Real-IP", testIP.ipv4)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.json, w.Header().Get("Content-Type"))
assert.JSONEq(t, expectedIPv4, w.Body.String())
req.RemoteAddr = net.JoinHostPort(testIP.ipv6, "1000")
req.Host = "test"
req.Header.Set("X-Real-IP", testIP.ipv6)
w = httptest.NewRecorder()
app.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.json, w.Header().Get("Content-Type"))
assert.JSONEq(t, expectedIPv6, w.Body.String())
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.json, w.Header().Get("Content-Type"))
assert.JSONEq(t, tt.expected, w.Body.String())
})
}
}
func TestAll(t *testing.T) {
@ -120,7 +182,7 @@ X-Real-Ip: 81.2.69.192
req, _ := http.NewRequest("GET", "/all", nil)
req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000")
req.Host = "test"
req.Header.Set("X-Real-IP", testIP.ipv4)
req.Header.Set(trustedHeader, testIP.ipv4)
req.Header.Set("Header1", "one")
w := httptest.NewRecorder()

View File

@ -16,6 +16,7 @@ type testIPs struct {
}
type contentTypes struct {
html string
text string
json string
}
@ -29,9 +30,12 @@ var (
ipv6ASN: "2a02:a800::1",
}
contentType = contentTypes{
html: "content-type: text/html; charset=utf-8",
text: "text/plain; charset=utf-8",
json: "application/json; charset=utf-8",
}
jsonIPv4 = `{"client_port":"1000","ip":"81.2.69.192","ip_version":4,"country":"United Kingdom","country_code":"GB","city":"London","latitude":51.5142,"longitude":-0.0931,"postal_code":"","time_zone":"Europe/London","asn":0,"asn_organization":"","host":"test","headers":{"X-Real-Ip":["81.2.69.192"]}}`
jsonIPv6 = `{"asn":3352, "asn_organization":"TELEFONICA DE ESPANA", "city":"", "client_port":"1000", "country":"", "country_code":"", "headers":{"X-Real-Ip":["2a02:9000::1"]}, "host":"test", "ip":"2a02:9000::1", "ip_version":6, "latitude":0, "longitude":0, "postal_code":"", "time_zone":""}`
)
const trustedHeader = "X-Real-IP"