Merge pull request #14339 from karalabe/faucet-block-banned-users

cmd/faucet: further user validations and bot protection
This commit is contained in:
Péter Szilágyi 2017-04-20 17:42:36 +03:00 committed by GitHub
commit d2fda73ad7
5 changed files with 109 additions and 29 deletions

View File

@ -29,6 +29,7 @@ import (
"io/ioutil" "io/ioutil"
"math/big" "math/big"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -73,6 +74,9 @@ var (
githubUser = flag.String("github.user", "", "GitHub user to authenticate with for Gist access") githubUser = flag.String("github.user", "", "GitHub user to authenticate with for Gist access")
githubToken = flag.String("github.token", "", "GitHub personal token to access Gists with") githubToken = flag.String("github.token", "", "GitHub personal token to access Gists with")
captchaToken = flag.String("captcha.token", "", "Recaptcha site key to authenticate client side")
captchaSecret = flag.String("captcha.secret", "", "Recaptcha secret key to authenticate server side")
logFlag = flag.Int("loglevel", 3, "Log level to use for Ethereum and the faucet") logFlag = flag.Int("loglevel", 3, "Log level to use for Ethereum and the faucet")
) )
@ -96,9 +100,10 @@ func main() {
} }
website := new(bytes.Buffer) website := new(bytes.Buffer)
template.Must(template.New("").Parse(string(tmpl))).Execute(website, map[string]interface{}{ template.Must(template.New("").Parse(string(tmpl))).Execute(website, map[string]interface{}{
"Network": *netnameFlag, "Network": *netnameFlag,
"Amount": *payoutFlag, "Amount": *payoutFlag,
"Period": period, "Period": period,
"Recaptcha": *captchaToken,
}) })
// Load and parse the genesis block requested by the user // Load and parse the genesis block requested by the user
blob, err := ioutil.ReadFile(*genesisFlag) blob, err := ioutil.ReadFile(*genesisFlag)
@ -297,7 +302,8 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
for { for {
// Fetch the next funding request and validate against github // Fetch the next funding request and validate against github
var msg struct { var msg struct {
URL string `json:"url"` URL string `json:"url"`
Captcha string `json:"captcha"`
} }
if err := websocket.JSON.Receive(conn, &msg); err != nil { if err := websocket.JSON.Receive(conn, &msg); err != nil {
return return
@ -308,6 +314,33 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
} }
log.Info("Faucet funds requested", "gist", msg.URL) log.Info("Faucet funds requested", "gist", msg.URL)
// If captcha verifications are enabled, make sure we're not dealing with a robot
if *captchaToken != "" {
form := url.Values{}
form.Add("secret", *captchaSecret)
form.Add("response", msg.Captcha)
res, err := http.PostForm("https://www.google.com/recaptcha/api/siteverify", form)
if err != nil {
websocket.JSON.Send(conn, map[string]string{"error": err.Error()})
continue
}
var result struct {
Success bool `json:"success"`
Errors json.RawMessage `json:"error-codes"`
}
err = json.NewDecoder(res.Body).Decode(&result)
res.Body.Close()
if err != nil {
websocket.JSON.Send(conn, map[string]string{"error": err.Error()})
continue
}
if !result.Success {
log.Warn("Captcha verification failed", "err", string(result.Errors))
websocket.JSON.Send(conn, map[string]string{"error": "Beep-boop, you're a robot!"})
continue
}
}
// Retrieve the gist from the GitHub Gist APIs // Retrieve the gist from the GitHub Gist APIs
parts := strings.Split(msg.URL, "/") parts := strings.Split(msg.URL, "/")
req, _ := http.NewRequest("GET", "https://api.github.com/gists/"+parts[len(parts)-1], nil) req, _ := http.NewRequest("GET", "https://api.github.com/gists/"+parts[len(parts)-1], nil)
@ -334,7 +367,7 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
continue continue
} }
if gist.Owner.Login == "" { if gist.Owner.Login == "" {
websocket.JSON.Send(conn, map[string]string{"error": "Nice try ;)"}) websocket.JSON.Send(conn, map[string]string{"error": "Anonymous Gists not allowed"})
continue continue
} }
// Iterate over all the files and look for Ethereum addresses // Iterate over all the files and look for Ethereum addresses
@ -348,6 +381,17 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
websocket.JSON.Send(conn, map[string]string{"error": "No Ethereum address found to fund"}) websocket.JSON.Send(conn, map[string]string{"error": "No Ethereum address found to fund"})
continue continue
} }
// Validate the user's existence since the API is unhelpful here
if res, err = http.Head("https://github.com/" + gist.Owner.Login); err != nil {
websocket.JSON.Send(conn, map[string]string{"error": err.Error()})
continue
}
res.Body.Close()
if res.StatusCode != 200 {
websocket.JSON.Send(conn, map[string]string{"error": "Invalid user... boom!"})
continue
}
// Ensure the user didn't request funds too recently // Ensure the user didn't request funds too recently
f.lock.Lock() f.lock.Lock()
var ( var (

View File

@ -51,9 +51,10 @@
<div class="input-group"> <div class="input-group">
<input id="gist" type="text" class="form-control" placeholder="GitHub Gist URL containing your Ethereum address..."> <input id="gist" type="text" class="form-control" placeholder="GitHub Gist URL containing your Ethereum address...">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-default" type="button" onclick="submit()">Give me Ether!</button> <button class="btn btn-default" type="button" onclick="{{if .Recaptcha}}grecaptcha.execute(){{else}}submit(){{end}}">Give me Ether!</button>
</span> </span>
</div> </div>{{if .Recaptcha}}
<div class="g-recaptcha" data-sitekey="{{.Recaptcha}}" data-callback="submit" data-size="invisible"></div>{{end}}
</div> </div>
</div> </div>
<div class="row" style="margin-top: 32px;"> <div class="row" style="margin-top: 32px;">
@ -76,7 +77,7 @@
<div class="row" style="margin-top: 32px;"> <div class="row" style="margin-top: 32px;">
<div class="col-lg-12"> <div class="col-lg-12">
<h3>How does this work?</h3> <h3>How does this work?</h3>
<p>This Ether faucet is running on the {{.Network}} network. To prevent malicious actors from exhausting all available funds or accumulating enough Ether to mount long running spam attacks, requests are tied to GitHub accounts. Anyone having a GitHub account may request funds within the permitted limit of <strong>{{.Amount}} Ether(s) / {{.Period}}</strong>.</p> <p>This Ether faucet is running on the {{.Network}} network. To prevent malicious actors from exhausting all available funds or accumulating enough Ether to mount long running spam attacks, requests are tied to GitHub accounts. Anyone having a GitHub account may request funds within the permitted limit of <strong>{{.Amount}} Ether(s) / {{.Period}}</strong>.{{if .Recaptcha}} The faucet is running invisible reCaptcha protection against bots.{{end}}</p>
<p>To request funds, simply create a <a href="https://gist.github.com/" target="_about:blank">GitHub Gist</a> with your Ethereum address pasted into the contents (the file name doesn't matter), copy paste the gists URL into the above input box and fire away! You can track the current pending requests below the input field to see how much you have to wait until your turn comes.</p> <p>To request funds, simply create a <a href="https://gist.github.com/" target="_about:blank">GitHub Gist</a> with your Ethereum address pasted into the contents (the file name doesn't matter), copy paste the gists URL into the above input box and fire away! You can track the current pending requests below the input field to see how much you have to wait until your turn comes.</p>
</div> </div>
</div> </div>
@ -89,8 +90,9 @@
var server; var server;
// Define the function that submits a gist url to the server // Define the function that submits a gist url to the server
var submit = function() { var submit = function({{if .Recaptcha}}captcha{{end}}) {
server.send(JSON.stringify({url: $("#gist")[0].value})); server.send(JSON.stringify({url: $("#gist")[0].value{{if .Recaptcha}}, captcha: captcha{{end}}}));{{if .Recaptcha}}
grecaptcha.reset();{{end}}
}; };
// Define a method to reconnect upon server loss // Define a method to reconnect upon server loss
var reconnect = function() { var reconnect = function() {
@ -134,10 +136,10 @@
} }
} }
server.onclose = function() { setTimeout(reconnect, 3000); }; server.onclose = function() { setTimeout(reconnect, 3000); };
server.onerror = function() { setTimeout(reconnect, 3000); };
} }
// Establish a websocket connection to the API server // Establish a websocket connection to the API server
reconnect(); reconnect();
</script> </script>{{if .Recaptcha}}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>{{end}}
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@ -54,6 +54,7 @@ CMD [ \
"/faucet", "--genesis", "/genesis.json", "--network", "{{.NetworkID}}", "--bootnodes", "{{.Bootnodes}}", "--ethstats", "{{.Ethstats}}", \ "/faucet", "--genesis", "/genesis.json", "--network", "{{.NetworkID}}", "--bootnodes", "{{.Bootnodes}}", "--ethstats", "{{.Ethstats}}", \
"--ethport", "{{.EthPort}}", "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", \ "--ethport", "{{.EthPort}}", "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", \
"--github.user", "{{.GitHubUser}}", "--github.token", "{{.GitHubToken}}", "--account.json", "/account.json", "--account.pass", "/account.pass" \ "--github.user", "{{.GitHubUser}}", "--github.token", "{{.GitHubToken}}", "--account.json", "/account.json", "--account.pass", "/account.pass" \
{{if .CaptchaToken}}, "--captcha.token", "{{.CaptchaToken}}", "--captcha.secret", "{{.CaptchaSecret}}"{{end}} \
]` ]`
// faucetComposefile is the docker-compose.yml file required to deploy and maintain // faucetComposefile is the docker-compose.yml file required to deploy and maintain
@ -75,7 +76,9 @@ services:
- FAUCET_AMOUNT={{.FaucetAmount}} - FAUCET_AMOUNT={{.FaucetAmount}}
- FAUCET_MINUTES={{.FaucetMinutes}} - FAUCET_MINUTES={{.FaucetMinutes}}
- GITHUB_USER={{.GitHubUser}} - GITHUB_USER={{.GitHubUser}}
- GITHUB_TOKEN={{.GitHubToken}}{{if .VHost}} - GITHUB_TOKEN={{.GitHubToken}}
- CAPTCHA_TOKEN={{.CaptchaToken}}
- CAPTCHA_SECRET={{.CaptchaSecret}}{{if .VHost}}
- VIRTUAL_HOST={{.VHost}} - VIRTUAL_HOST={{.VHost}}
- VIRTUAL_PORT=8080{{end}} - VIRTUAL_PORT=8080{{end}}
restart: always restart: always
@ -97,6 +100,8 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"EthPort": config.node.portFull, "EthPort": config.node.portFull,
"GitHubUser": config.githubUser, "GitHubUser": config.githubUser,
"GitHubToken": config.githubToken, "GitHubToken": config.githubToken,
"CaptchaToken": config.captchaToken,
"CaptchaSecret": config.captchaSecret,
"FaucetName": strings.Title(network), "FaucetName": strings.Title(network),
"FaucetAmount": config.amount, "FaucetAmount": config.amount,
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
@ -113,6 +118,8 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"EthName": config.node.ethstats[:strings.Index(config.node.ethstats, ":")], "EthName": config.node.ethstats[:strings.Index(config.node.ethstats, ":")],
"GitHubUser": config.githubUser, "GitHubUser": config.githubUser,
"GitHubToken": config.githubToken, "GitHubToken": config.githubToken,
"CaptchaToken": config.captchaToken,
"CaptchaSecret": config.captchaSecret,
"FaucetAmount": config.amount, "FaucetAmount": config.amount,
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
}) })
@ -135,18 +142,20 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
// faucetInfos is returned from an faucet status check to allow reporting various // faucetInfos is returned from an faucet status check to allow reporting various
// configuration parameters. // configuration parameters.
type faucetInfos struct { type faucetInfos struct {
node *nodeInfos node *nodeInfos
host string host string
port int port int
amount int amount int
minutes int minutes int
githubUser string githubUser string
githubToken string githubToken string
captchaToken string
captchaSecret string
} }
// String implements the stringer interface. // String implements the stringer interface.
func (info *faucetInfos) String() string { func (info *faucetInfos) String() string {
return fmt.Sprintf("host=%s, api=%d, eth=%d, amount=%d, minutes=%d, github=%s, ethstats=%s", info.host, info.port, info.node.portFull, info.amount, info.minutes, info.githubUser, info.node.ethstats) return fmt.Sprintf("host=%s, api=%d, eth=%d, amount=%d, minutes=%d, github=%s, captcha=%v, ethstats=%s", info.host, info.port, info.node.portFull, info.amount, info.minutes, info.githubUser, info.captchaToken != "", info.node.ethstats)
} }
// checkFaucet does a health-check against an faucet server to verify whether // checkFaucet does a health-check against an faucet server to verify whether
@ -200,11 +209,13 @@ func checkFaucet(client *sshClient, network string) (*faucetInfos, error) {
keyJSON: keyJSON, keyJSON: keyJSON,
keyPass: keyPass, keyPass: keyPass,
}, },
host: host, host: host,
port: port, port: port,
amount: amount, amount: amount,
minutes: minutes, minutes: minutes,
githubUser: infos.envvars["GITHUB_USER"], githubUser: infos.envvars["GITHUB_USER"],
githubToken: infos.envvars["GITHUB_TOKEN"], githubToken: infos.envvars["GITHUB_TOKEN"],
captchaToken: infos.envvars["CAPTCHA_TOKEN"],
captchaSecret: infos.envvars["CAPTCHA_SECRET"],
}, nil }, nil
} }

View File

@ -71,7 +71,7 @@ func (w *wizard) deployFaucet() {
// Accessing GitHub gists requires API authorization, retrieve it // Accessing GitHub gists requires API authorization, retrieve it
if infos.githubUser != "" { if infos.githubUser != "" {
fmt.Println() fmt.Println()
fmt.Printf("Reused previous (%s) GitHub API authorization (y/n)? (default = yes)\n", infos.githubUser) fmt.Printf("Reuse previous (%s) GitHub API authorization (y/n)? (default = yes)\n", infos.githubUser)
if w.readDefaultString("y") != "y" { if w.readDefaultString("y") != "y" {
infos.githubUser, infos.githubToken = "", "" infos.githubUser, infos.githubToken = "", ""
} }
@ -109,6 +109,29 @@ func (w *wizard) deployFaucet() {
return return
} }
} }
// Accessing the reCaptcha service requires API authorizations, request it
if infos.captchaToken != "" {
fmt.Println()
fmt.Println("Reuse previous reCaptcha API authorization (y/n)? (default = yes)")
if w.readDefaultString("y") != "y" {
infos.captchaToken, infos.captchaSecret = "", ""
}
}
if infos.captchaToken == "" {
// No previous authorization (or old one discarded)
fmt.Println()
fmt.Println("Enable reCaptcha protection against robots (y/n)? (default = no)")
if w.readDefaultString("n") == "y" {
// Captcha protection explicitly requested, read the site and secret keys
fmt.Println()
fmt.Printf("What is the reCaptcha site key to authenticate human users?\n")
infos.captchaToken = w.readString()
fmt.Println()
fmt.Printf("What is the reCaptcha secret key to verify authentications? (won't be echoed)\n")
infos.captchaSecret = w.readPassword()
}
}
// Figure out where the user wants to store the persistent data // Figure out where the user wants to store the persistent data
fmt.Println() fmt.Println()
if infos.node.datadir == "" { if infos.node.datadir == "" {