diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b9525d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +nginx-js-challenge +.idea/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..44a4e46 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,40 @@ +run: + deadline: 5m + skip-dirs: + - vendor/.* +linters-settings: + errcheck: + check-type-assertions: true + govet: + check-shadowing: true + gocyclo: + min-complexity: 50 + maligned: + suggest-new: true + depguard: + list-type: blacklist + include-go-root: true + packages: + - github.com/davecgh/go-spew/spew + misspell: + locale: US + lll: + line-length: 200 + funlen: + lines: 500 + statements: 500 + gocognit: + min-complexity: 80 + unparam: + # call graph construction algorithm (cha, rta). In general, use cha for libraries, + # and rta for programs with main packages. Default is cha. + algo: cha + prealloc: + for-loops: true +linters: + enable-all: true + disable: + - dupl + - godot + - gomnd + - nestif diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc20a78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 s3rj1k + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c57f1bb --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +GO_BIN ?= go +ENV_BIN ?= env +OUT_BIN = nginx-js-challenge + +export PATH := $(PATH):/usr/local/go/bin + +all: clean build + +build: + $(GO_BIN) mod tidy + $(ENV_BIN) CGO_ENABLED=1 GOOS=linux $(GO_BIN) build -ldflags '-s -w -extldflags "-static"' -o $(OUT_BIN) -v + +update: + $(ENV_BIN) GOPROXY=direct GOPRIVATE="github.com/s3rj1k/*" $(GO_BIN) get -u + $(GO_BIN) get -u github.com/golangci/golangci-lint/cmd/golangci-lint + $(GO_BIN) get -u github.com/mgechev/revive + $(GO_BIN) mod tidy + +clean: + $(GO_BIN) clean + rm -f $(OUT_BIN) + +test: + $(GO_BIN) test -failfast ./... + +lint: + golangci-lint run ./... + revive -config revive.toml -exclude ./vendor/... ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d9d553 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Build +## Build binaries +```shell script +make build +``` + +## Build Deb Package +```shell script +apt install make devscripts debhelper build-essential dh-systemd +debuild -us -uc -b +``` + +# Usage +## Start nginx-js-challenge backend +```shell script +./nginx-js-challenge -address=unix:/run/nginx-js-challenge.sock +``` +## Test nginx-js-challenge backend +```shell script +curl --unix-socket /run/nginx-js-challenge.sock http:/example.com +``` + +## Nginx configuration +The ./nginx dir contains the vhost configuration template. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..16b4161 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,11 @@ +nginx-js-challenge (20191111) UNRELEASED; urgency=low + + * More internal updates + + -- s3rj1k Thu, 31 Oct 2019 12:00:00 +0200 + +nginx-js-challenge (20190610) UNRELEASED; urgency=low + + * Initial release + + -- s3rj1k Mon, 10 Jun 2019 12:00:00 +0200 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..bafb1bd --- /dev/null +++ b/debian/control @@ -0,0 +1,13 @@ +Source: nginx-js-challenge +Section: net +Priority: optional +Maintainer: s3rj1k +Build-Depends: debhelper (>= 9), dh-systemd (>= 1.5) +Standards-Version: 3.9.6 + +Package: nginx-js-challenge +Architecture: amd64 +Depends: ${shlibs:Depends}, lsb-base +Suggests: nginx +Description: Nginx JS Challenge + This package contains simple Nginx JS challenge daemon. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..663e5e5 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,5 @@ +This package was debianized by s3rj1k +on Thu, 16 May 2019 15:00:00 +0200. + +Files: * +Copyright: s3rj1k 2019 diff --git a/debian/nginx-js-challenge.default b/debian/nginx-js-challenge.default new file mode 100644 index 0000000..e7cbd9d --- /dev/null +++ b/debian/nginx-js-challenge.default @@ -0,0 +1,5 @@ +# Defaults for nginx-js-challenge initscript +# sourced by /etc/init.d/nginx-js-challenge + +# Additional options that are passed to nginx-js-challenge +DAEMON_ARGS="" diff --git a/debian/nginx-js-challenge.init b/debian/nginx-js-challenge.init new file mode 100644 index 0000000..dc8c53f --- /dev/null +++ b/debian/nginx-js-challenge.init @@ -0,0 +1,99 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: nginx-js-challenge +# Required-Start: $network $remote_fs $local_fs +# Required-Stop: $network $remote_fs $local_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Stop/Start nginx-js-challenge +### END INIT INFO + +# Author: s3rj1k + +PATH=/sbin:/usr/sbin:/bin:/usr/bin + +if [ -L $0 ]; then + SCRIPTNAME=`/bin/readlink -f $0` +else + SCRIPTNAME=$0 +fi + +NAME="nginx-js-challenge" +DAEMON=${DAEMON:-/usr/sbin/nginx-js-challenge} +PIDFILE=${PIDFILE:-/run/nginx-js-challenge.pid} +DAEMON_ARGS="" + +defaultconfig=`/usr/bin/basename $SCRIPTNAME` +[ -r /etc/default/$defaultconfig ] && . /etc/default/$defaultconfig + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +do_start() +{ + start-stop-daemon --start --quiet --background --no-close --make-pidfile --pidfile $PIDFILE --exec $DAEMON -- \ + $DAEMON_ARGS > /var/log/$NAME.log 2>&1 + RETVAL="$?" + return "$RETVAL" +} + +do_stop() +{ + start-stop-daemon --stop --quiet --oknodo --retry=TERM/30/KILL/5 --pidfile $PIDFILE + RETVAL="$?" + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting" "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc -p "$PIDFILE" "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + restart) + log_daemon_msg "Restarting" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + log_action_msg "Usage: $SCRIPTNAME {start|stop|status|restart}" + exit 3 + ;; +esac + +exit $RETVAL diff --git a/debian/nginx-js-challenge.lintian-overrides b/debian/nginx-js-challenge.lintian-overrides new file mode 100644 index 0000000..614488e --- /dev/null +++ b/debian/nginx-js-challenge.lintian-overrides @@ -0,0 +1,6 @@ +nginx-js-challenge: hardening-no-relro * +nginx-js-challenge: hardening-no-pie * +nginx-js-challenge: binary-without-manpage * +nginx-js-challenge: new-package-should-close-itp-bug +nginx-js-challenge: description-is-pkg-name * +nginx-js-challenge: init.d-script-does-not-implement-required-option * diff --git a/debian/nginx-js-challenge.service b/debian/nginx-js-challenge.service new file mode 100644 index 0000000..2220448 --- /dev/null +++ b/debian/nginx-js-challenge.service @@ -0,0 +1,20 @@ +[Unit] +Description=Nginx JS challenge service +After=network.target + +[Service] +Type=simple + +Environment="DAEMON_ARGS=" +EnvironmentFile=-/etc/default/nginx-js-challenge + +ExecStart=/usr/sbin/nginx-js-challenge -log-date-time=false ${DAEMON_ARGS} + +Restart=on-failure +RestartSec=10 + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..c32904e --- /dev/null +++ b/debian/rules @@ -0,0 +1,13 @@ +#!/usr/bin/make -f + +%: + dh $@ --with systemd + +override_dh_builddeb: + dh_builddeb -- -Zgzip + +override_dh_auto_install: + dh_install nginx-js-challenge usr/sbin + dh_install nginx/js_challenge_include.conf /usr/share/doc/nginx-js-challenge/ + dh_install nginx/js_challenge_main.conf /usr/share/doc/nginx-js-challenge/ + dh_install nginx/js_challenge_server.conf /usr/share/doc/nginx-js-challenge/ diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/font.go b/font.go new file mode 100644 index 0000000..b55d3c1 --- /dev/null +++ b/font.go @@ -0,0 +1,33 @@ +package main + +import ( + "net/url" + "strings" +) + +// isFontInURL checks that web-font is requested in URL. +func isFontInURL(uri string) bool { + u, err := url.Parse(uri) + if err != nil { + return false + } + + value := strings.ToLower(u.Path) + + switch { + case strings.HasSuffix(value, ".eot"): + return true + case strings.HasSuffix(value, ".otf"): + return true + case strings.HasSuffix(value, ".svg"): + return true + case strings.HasSuffix(value, ".ttf"): + return true + case strings.HasSuffix(value, ".woff"): + return true + case strings.HasSuffix(value, ".woff2"): + return true + } + + return false +} diff --git a/gc.go b/gc.go new file mode 100644 index 0000000..e50d2ed --- /dev/null +++ b/gc.go @@ -0,0 +1,46 @@ +package main + +import ( + "net/http" + "sync" + "time" +) + +func cleanDB(db *sync.Map) { + for { + // sleep inside infinite loop + time.Sleep(15 * time.Second) + + // range over db + db.Range(func(key interface{}, val interface{}) bool { + // cast key to string + if id, ok := key.(string); ok { + // cast value to challenge record + if record, ok := val.(challengeDBRecord); ok { + // check expiration time + if record.Expires.Before(time.Now()) { + Debug.Printf( + "%d, Domain:'%s', ID:'%s', %s\n", + http.StatusOK, record.Domain, + id, messageExpiredRecord, + ) + + // check then id is NOT UUID + if !reUUID.MatchString(id) { + Bot.Printf( + "%d, Domain:'%s', Addr:'%s', UA:'%s'\n", + http.StatusTeapot, record.Domain, + record.Address, record.UserAgent, + ) + } + + // delete key + db.Delete(key) + } + } + } + + return true + }) + } +} diff --git a/globals.go b/globals.go new file mode 100644 index 0000000..e9e4112 --- /dev/null +++ b/globals.go @@ -0,0 +1,142 @@ +package main + +import ( + "html/template" + "log" + "net/http" + "regexp" + "sync" + "time" +) + +const ( + // defines maximum number for cryptographic nonce + maxNonce = 250 + + // authentication cookie name + authenticationName = "0dkZynp3NoRHgFUFbf" + // number of seconds for authentication cookie expiration + authenticationExpirationSeconds = 86400 + + // challenge cookie name + challengeName = "Wg9y31L7XZPkl0v4r7" + // number of seconds for challenge hash expiration + challengeExpirationSeconds = 60 + + // challenge response name + responseName = "dJzSKzFqxk327Yr3" + + // number of nanoseconds in second + nanoSecondsInSecond = 1000000000 + + // HTTP code for non-authorized request, used in nginx redirects + unAuthorizedAccess = http.StatusUnauthorized + + // regex for UUIDv4 validation + regExpUUIDv4 = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[8,9,a,b][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$` + + // URL for JS hash library + defaultJSHashLibraryURL = "https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js" +) + +const ( + messageOnlyGetMethod = "only GET method" + messageOnlyGetOrPostMethod = "only GET or POST method" + messageOnlyPostMethod = "only POST method" + + messageFailedEntropy = "entropy failure" + + messageFailedHTMLRender = "HTML render failure" + messageFailedHTTPResponse = "HTTP response failure" + + messageExpiredChallenge = "expired challenge" + messageInvalidChallenge = "invalid challenge" + messageInvalidResponse = "invalid response" + + messageExpiredRecord = "expired record" + messageUnknownChallenge = "unknown challenge" + + messageEmptyAuthentication = "empty authentication" + messageExpiredAuthentication = "authentication expired" + messageInvalidAuthenticationDomain = "invalid authentication domain" + messageInvalidUserAgent = "invalid authentication user-agent" + messageUnknownAuthentication = "unknown authentication" + messageValidAuthentication = "valid authentication" + + messageAllowOptionsRequest = "allow OPTIONS method" + messageAllowWebFont = "allow web font" +) + +type challengeDBRecord struct { + // Domain defines valid challenge/authentication domain + Domain string + // UserAgent stores UA that originated from HTTP request + UserAgent string + // Expires defines challenge/authentication TTL + Expires time.Time + + // Address stores address that originated from HTTP request + Address string + + // Nonce defines cryptographic nonce for challenge request + Nonce string +} + +// nolint: gochecknoglobals +var ( + // challenge HTML template + challengeTemplate *template.Template + // challenge Lite HTML template + challengeLiteTemplate *template.Template + + // in memory key:value db + db sync.Map + + // mutex for thread-safety + mu sync.Mutex + + // compiled RegExp for UUIDv4 + reUUID *regexp.Regexp + + // IP:PORT or unix socket path + cmdAddress string + // log date/time + cmdLogDateTime bool + // enable debug logging + cmdDebug bool + + // empty favicon.ico + favicon = []byte{ + 000, 000, 001, 000, 001, 000, 016, 016, + 002, 000, 001, 000, 001, 000, 176, 000, + 000, 000, 022, 000, 000, 000, 040, 000, + 000, 000, 016, 000, 000, 000, 032, 000, + 000, 000, 001, 000, 001, 000, 000, 000, + 000, 000, 128, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 255, 255, 255, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 000, 000, + 000, 000, 000, 000, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, 255, 255, + 000, 000, 255, 255, 000, 000, + } + + // Logging levels + Info *log.Logger + Error *log.Logger + Debug *log.Logger + Bot *log.Logger +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8fd40ec --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/s3rj1k/nginx-js-challenge + +go 1.13 + +require golang.org/x/net v0.0.0-20201021035429-f5854403a974 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22544d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/handle.go b/handle.go new file mode 100644 index 0000000..d666dc8 --- /dev/null +++ b/handle.go @@ -0,0 +1,627 @@ +package main + +import ( + "errors" + "net/http" + "strconv" + "strings" + "syscall" + "time" + + "golang.org/x/net/publicsuffix" +) + +func faviconHandler(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write(favicon); err != nil { + Error.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusInternalServerError, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageFailedHTTPResponse, + ) + } +} + +func challengeHandle(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + renderHandle(w, r) + case http.MethodPost: + validateHandle(w, r) + case http.MethodOptions: + // OPTIONS is needed for CORS to function properly, we allow all OPTIONS requests when specific header is passed + if strings.EqualFold(r.Header.Get("X-Allow-OPTIONS"), "TRUE") { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusAccepted, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageAllowOptionsRequest, + ) + + return + } + + fallthrough + default: + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusMethodNotAllowed, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageOnlyGetOrPostMethod, + ) + + // set default allowed headers + allowHeader := []string{ + http.MethodGet, + http.MethodPost, + } + + if strings.EqualFold(r.Header.Get("X-Allow-OPTIONS"), "TRUE") { + // add OPTIONS to allowed headers + allowHeader = append(allowHeader, http.MethodOptions) + } + + // return proper HTTP error with headers + w.Header().Set( + "Allow", + strings.Join(allowHeader, ", "), + ) + + http.Error(w, messageOnlyGetOrPostMethod, http.StatusMethodNotAllowed) + + return + } +} + +func renderHandle(w http.ResponseWriter, r *http.Request) { + // allow only GET method + if r.Method != http.MethodGet { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusMethodNotAllowed, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageOnlyGetMethod, + ) + + // return proper HTTP error with headers + w.Header().Set("Allow", http.MethodGet) + http.Error(w, messageOnlyGetMethod, http.StatusMethodNotAllowed) + + return + } + + // define domain for a cookie + domain := r.Header.Get("X-Forwarded-Host") + + // compute wildcard domain cookie when appropriate configuration header present + if strings.EqualFold(r.Header.Get("X-TLDPlusOne"), "TRUE") { + if val, err := publicsuffix.EffectiveTLDPlusOne(r.Header.Get("X-Forwarded-Host")); err == nil { + domain = "." + val + } + } + + // clean old invalid cookies + http.SetCookie(w, &http.Cookie{ + Domain: domain, + Name: authenticationName, + Value: "", + Expires: time.Unix(0, 0), + }) + http.SetCookie(w, &http.Cookie{ + Name: authenticationName, + Value: "", + Expires: time.Unix(0, 0), + }) + + // set to True when captcha requested with lite template flag + var isLiteTemplate bool + + // check for lite template header + if strings.EqualFold(r.Header.Get("X-LiteTemplate"), "TRUE") { + isLiteTemplate = true + } + + // generate new nonce + nonce := genNewNonce() + // generate challenge hash + challenge := getStringHash(r.UserAgent(), nonce) + // set how long cookie is valid + challengeTTL := time.Duration(challengeExpirationSeconds * nanoSecondsInSecond) + // generate expire date for challenge hash + expires := time.Now().Add(challengeTTL) + + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Nonce:'%s', Challenge:'%s', TTL:'%s'\n", + http.StatusOK, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), nonce, + challenge, challengeTTL, + ) + + // get JS HASH library URL + jsHashLibraryURL := r.Header.Get("X-JS-Hash-Library-URL") + if len(jsHashLibraryURL) == 0 { + jsHashLibraryURL = defaultJSHashLibraryURL + } + + // populate struct with needed data for template render + data := struct { + MaxNonce string + ChallengeName string + ResponseName string + JSHashLibraryURL string + }{ + MaxNonce: strconv.Itoa(maxNonce), + ChallengeName: challengeName, + ResponseName: responseName, + JSHashLibraryURL: jsHashLibraryURL, + } + + // store challenge hash to db + db.Store(challenge, + challengeDBRecord{ + Domain: domain, + UserAgent: r.UserAgent(), + Expires: expires, + + Address: r.Header.Get("X-Real-IP"), + Nonce: nonce, + }, + ) + + // https://www.fastly.com/blog/clearing-cache-browser + // https://www.w3.org/TR/clear-site-data/ + // Broken on Google Chrome + // w.Header().Set("Clear-Site-Data", `"cache"`) + + // set cookie for wildcard domain cookie, domain starts with '.' + if strings.HasPrefix(domain, ".") { + http.SetCookie(w, &http.Cookie{ + Domain: domain, + Name: challengeName, + Value: challenge, + Expires: expires, + MaxAge: int(expires.Unix() - time.Now().Unix()), + Secure: false, + HttpOnly: false, + SameSite: http.SameSiteNoneMode, + }) + } else { // non-wildcard cookie + http.SetCookie(w, &http.Cookie{ + Name: challengeName, + Value: challenge, + Expires: expires, + MaxAge: int(expires.Unix() - time.Now().Unix()), + Secure: false, + HttpOnly: false, + SameSite: http.SameSiteStrictMode, + }) + } + + var err error + + // render challenge template + if isLiteTemplate { + err = challengeLiteTemplate.Execute(w, data) + } else { + err = challengeTemplate.Execute(w, data) + } + + if err != nil { + // ignore buffer errors + if errors.Is(err, syscall.EPIPE) { + return + } + + Error.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', %s\n", + http.StatusInternalServerError, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), + messageFailedHTMLRender, + ) + + // return proper HTTP error + http.Error(w, messageFailedHTMLRender, http.StatusInternalServerError) + + return + } +} + +func validateHandle(w http.ResponseWriter, r *http.Request) { + // allow only POST method + if r.Method != http.MethodPost { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusMethodNotAllowed, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageOnlyPostMethod, + ) + + // return proper HTTP error with headers + w.Header().Set("Allow", http.MethodPost) + http.Error(w, messageOnlyPostMethod, http.StatusMethodNotAllowed) + + return + } + + // define domain for a cookie + domain := r.Header.Get("X-Forwarded-Host") + + // compute wildcard domain cookie when appropriate configuration header present + if strings.EqualFold(r.Header.Get("X-TLDPlusOne"), "TRUE") { + if val, err := publicsuffix.EffectiveTLDPlusOne(r.Header.Get("X-Forwarded-Host")); err == nil { + domain = "." + val + } + } + + // remove challenge for wildcard domain cookie, domain starts with '.' + if strings.HasPrefix(domain, ".") { + http.SetCookie(w, &http.Cookie{ + Domain: domain, + Name: challengeName, + Value: "", + Expires: time.Unix(0, 0), + }) + } else { // non-wildcard cookie + http.SetCookie(w, &http.Cookie{ + Name: challengeName, + Value: "", + Expires: time.Unix(0, 0), + }) + } + + // get challenge value from request + challenge := r.PostFormValue(challengeName) + // get challenge response value from request + response := r.PostFormValue(responseName) + + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Response:'%s', Challenge:'%s'\n", + http.StatusOK, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), + response, challenge, + ) + + // https://www.fastly.com/blog/clearing-cache-browser + // https://www.w3.org/TR/clear-site-data/ + // Broken on Google Chrome + // w.Header().Set("Clear-Site-Data", `"cache"`) + + // lookup challenge hash in db + val, ok := db.Load(challenge) + if !ok { + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Challenge:'%s', %s\n", + http.StatusSeeOther, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), challenge, + messageUnknownChallenge, + ) + + // redirect to self + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + // check challenge hash record + record, ok := val.(challengeDBRecord) + if !ok { + Error.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Challenge:'%s', %s\n", + http.StatusInternalServerError, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), challenge, + messageUnknownChallenge, + ) + + // return proper HTTP error + http.Error(w, messageUnknownChallenge, http.StatusInternalServerError) + + return + } + + // check that challenge hash is valid for domain + if !strings.EqualFold(domain, record.Domain) { + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Challenge:'%s', %s\n", + http.StatusSeeOther, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), challenge, + messageInvalidChallenge, + ) + + // redirect to self + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + // check challenge hash expiration + if record.Expires.Before(time.Now()) { + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Challenge:'%s', %s\n", + http.StatusSeeOther, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), challenge, + messageExpiredChallenge, + ) + + // redirect to self + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + // validate challenge response + if getStringHash(r.UserAgent(), response) != challenge { + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Challenge:'%s', %s\n", + http.StatusSeeOther, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), challenge, + messageInvalidResponse, + ) + + // redirect to self + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + // generate ID for cookie value + id, err := genUUID() + if err != nil { + Error.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', %s\n", + http.StatusInternalServerError, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), messageFailedEntropy, + ) + + // return proper HTTP error + http.Error(w, messageFailedEntropy, http.StatusInternalServerError) + + return + } + + // set how long cookie is valid + authenticationTTL := time.Duration(authenticationExpirationSeconds * nanoSecondsInSecond) + // generate expire date for authentication hash + expires := time.Now().Add(authenticationTTL) + + Info.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Response:'%s', Challenge:'%s', Auth:'%s', TTL:'%s'\n", + http.StatusOK, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), response, + challenge, id, authenticationTTL, + ) + + // challenge is valid, invalidating used challenge hash + db.Delete(challenge) + + // store challenge authentication hash to db + db.Store(id, + challengeDBRecord{ + Domain: domain, + UserAgent: r.UserAgent(), + Expires: expires, + + Address: r.Header.Get("X-Real-IP"), + }, + ) + + // set cookie for wildcard domain cookie, domain starts with '.' + if strings.HasPrefix(domain, ".") { + http.SetCookie(w, &http.Cookie{ + Domain: domain, + Name: authenticationName, + Value: id, + Expires: expires, + MaxAge: int(expires.Unix() - time.Now().Unix()), + Secure: false, + HttpOnly: false, + SameSite: http.SameSiteNoneMode, + }) + } else { // non-wildcard cookie + http.SetCookie(w, &http.Cookie{ + Name: authenticationName, + Value: id, + Expires: expires, + MaxAge: int(expires.Unix() - time.Now().Unix()), + Secure: false, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + } + + // redirect to self + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func authHandle(w http.ResponseWriter, r *http.Request) { + // allow web font for '@font-face' request from CSS + if strings.EqualFold(r.Header.Get("X-Allow-Web-Font"), "TRUE") && + isFontInURL(r.Header.Get("X-Original-URI")) { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + http.StatusOK, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageAllowWebFont, + ) + + return + } + + // get challenge cookie value from request + auth, err := r.Cookie(authenticationName) + if err != nil || auth == nil { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', UA:'%s', %s\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + r.UserAgent(), messageEmptyAuthentication, + ) + + // return proper HTTP error + http.Error(w, messageEmptyAuthentication, unAuthorizedAccess) + + return + } + + // define domain for a cookie + domain := r.Header.Get("X-Forwarded-Host") + + // compute wildcard domain cookie when appropriate configuration header present + if strings.EqualFold(r.Header.Get("X-TLDPlusOne"), "TRUE") { + if val, err := publicsuffix.EffectiveTLDPlusOne(r.Header.Get("X-Forwarded-Host")); err == nil { + domain = "." + val + } + } + + // lookup cookie value in db + val, ok := db.Load(auth.Value) + if !ok { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageUnknownAuthentication, + ) + + // return proper HTTP error + http.Error(w, messageUnknownAuthentication, unAuthorizedAccess) + + return + } + + // check challenge hash record + record, ok := val.(challengeDBRecord) + if !ok { + Error.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageUnknownAuthentication, + ) + + // return proper HTTP error + http.Error(w, messageUnknownAuthentication, unAuthorizedAccess) + + return + } + + // check that cookie is valid for domain + if !strings.EqualFold(domain, record.Domain) { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s (%s)\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageInvalidAuthenticationDomain, + record.Domain, + ) + + // when we switch form wildcard to none-wildcard cookie we need to clean DB records for this domain + if strings.EqualFold( + strings.TrimPrefix(domain, "."), + strings.TrimPrefix(record.Domain, "."), + ) { + db.Delete(auth.Value) + } + + // return proper HTTP error + http.Error(w, messageInvalidAuthenticationDomain, unAuthorizedAccess) + + return + } + + // check that cookie is valid for UA + if !strings.EqualFold(r.UserAgent(), record.UserAgent) { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s (%s)\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageInvalidUserAgent, record.UserAgent, + ) + + // return proper HTTP error + http.Error(w, messageInvalidUserAgent, unAuthorizedAccess) + + return + } + + // check cookie expiration + if !record.Expires.After(time.Now()) { + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s\n", + unAuthorizedAccess, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageExpiredAuthentication, + ) + + // return proper HTTP error + http.Error(w, messageExpiredAuthentication, unAuthorizedAccess) + } + + Debug.Printf( + "%d, RAddr:'%s', URL:'%s%s', Dom:'%s', UA:'%s', Auth:'%s', %s\n", + http.StatusOK, + r.Header.Get("X-Real-IP"), + r.Header.Get("X-Forwarded-Host"), + r.Header.Get("X-Original-URI"), + domain, r.UserAgent(), auth.Value, + messageValidAuthentication, + ) +} diff --git a/hash.go b/hash.go new file mode 100644 index 0000000..56260f3 --- /dev/null +++ b/hash.go @@ -0,0 +1,16 @@ +package main + +//nolint: gosec +import ( + "crypto/sha1" + "fmt" + "strings" +) + +// getStringHash creates hash as string from input string +func getStringHash(text ...string) string { + b := []byte(strings.Join(text, "")) + h := sha1.Sum(b) // nolint: gosec + + return fmt.Sprintf("%x", h) +} diff --git a/init.go b/init.go new file mode 100644 index 0000000..9b7ab8d --- /dev/null +++ b/init.go @@ -0,0 +1,69 @@ +package main + +import ( + "flag" + "io" + "io/ioutil" + "log" + "math/rand" + "os" + "regexp" + "time" +) + +// nolint: gochecknoinits +func init() { + var err error + + // command line flags + flag.StringVar(&cmdAddress, "address", "unix:/run/nginx-js-challenge.sock", `IP:PORT or Unix Socket path prefixd with "unix:"`) + flag.BoolVar(&cmdLogDateTime, "log-date-time", true, "add date/time to log output") + flag.BoolVar(&cmdDebug, "debug", false, "enable debug logging") + flag.Parse() + + // seed random source + rand.Seed(time.Now().UTC().UnixNano()) + + // define custom log flags + var logFlag int + if cmdLogDateTime { + logFlag = log.Ldate | log.Ltime + } else { + logFlag = 0 + } + // define debug log output + var debugWriter io.Writer + if cmdDebug { + debugWriter = os.Stdout + logFlag |= log.Lshortfile + } else { + debugWriter = ioutil.Discard + } + + // initialize loggers + Info = log.New( + os.Stdout, + "INFO: ", + logFlag, + ) + Error = log.New( + os.Stderr, + "ERROR: ", + logFlag, + ) + Debug = log.New( + debugWriter, + "DEBUG: ", + logFlag, + ) + Bot = log.New( + os.Stdout, + "BOT: ", + logFlag, + ) + + reUUID, err = regexp.Compile(regExpUUIDv4) + if err != nil { + Error.Fatalf("regexp compile error: %s\n", err.Error()) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..491a5d3 --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "html/template" + "net" + "net/http" + "os" + "strings" + "syscall" +) + +func main() { + var err error + + // prepare challenge HTML template + challengeTemplate, err = template.New("challenge.html").Parse(challengeHTMLTemplate) + if err != nil { + Error.Fatalf("challenge service template error: %s\n", err.Error()) + } + + // prepare challenge Lite HTML template + challengeLiteTemplate, err = template.New("challenge-lite.html").Parse(challengeLightHTMLTemplate) + if err != nil { + Error.Fatalf("challenge service template error: %s\n", err.Error()) + } + + // create new HTTP mux and define HTTP routes + mux := http.NewServeMux() + mux.HandleFunc("/", challengeHandle) + mux.HandleFunc("/auth", authHandle) + mux.HandleFunc("/favicon.ico", faviconHandler) + + // run DB cleaner to clean expired keys + go cleanDB(&db) + + // define net listner for HTTP serve function + var nl net.Listener + + switch { + case strings.HasPrefix(cmdAddress, "unix:"): + // get socket path + socket := strings.TrimPrefix(cmdAddress, "unix:") + + // remove old Unix socket + if _, err = os.Stat(socket); !os.IsNotExist(err) { + if err = syscall.Unlink(socket); err != nil { + Error.Fatalf("challenge service socket error: %s\n", err.Error()) + } + } + + // listen on unix socket + nl, err = net.Listen("unix", socket) + if err != nil { + Error.Fatalf("challenge service socket error: %s\n", err.Error()) + } + + // close unix socket on exit + defer func() { + if err = nl.Close(); err != nil { + Error.Fatalf("challenge service socket error: %s\n", err.Error()) + } + }() + + // change unix socket permissions + if err = os.Chmod(socket, os.FileMode(0777)); err != nil { + Error.Fatalf("challenge service socket error: %s\n", err.Error()) + } + default: + // listen on TCP + nl, err = net.Listen("tcp", cmdAddress) + if err != nil { + Error.Fatalf("challenge service TCP error: %s\n", err.Error()) + } + } + + // start challenge server + err = http.Serve(nl, mux) + if err != nil { + Error.Fatalf("challenge service start error: %s\n", err.Error()) + } +} diff --git a/nginx/js_challenge_include.conf b/nginx/js_challenge_include.conf new file mode 100644 index 0000000..2fa1d79 --- /dev/null +++ b/nginx/js_challenge_include.conf @@ -0,0 +1,20 @@ +limit_req zone=zone burst=10; +limit_conn perip 10; + +auth_request /auth; +error_page 401 = @js_challenge; + +# proxy_set_header Host $host; +# proxy_set_header User-Agent $http_user_agent; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Host $host; +# proxy_set_header X-Original-URI $request_uri; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Server-IP $server_addr; +# proxy_set_header X-Unprotected-Remote-IP $remote_addr; + +# proxy_pass_header Set-Cookie; + +# proxy_http_version 1.1; + +# proxy_pass https://{{ BACKEND }}; diff --git a/nginx/js_challenge_main.conf b/nginx/js_challenge_main.conf new file mode 100644 index 0000000..18adcf4 --- /dev/null +++ b/nginx/js_challenge_main.conf @@ -0,0 +1,11 @@ +upstream js_challenge_backend { + server unix:/run/nginx-js-challenge.sock; +} + +limit_req_zone $binary_remote_addr zone=zone:10m rate=30r/s; +limit_conn_zone $binary_remote_addr zone=perip:10m; + +geo $auth { + default 0; + 127.0.0.1/32 1; +} diff --git a/nginx/js_challenge_server.conf b/nginx/js_challenge_server.conf new file mode 100644 index 0000000..fb06372 --- /dev/null +++ b/nginx/js_challenge_server.conf @@ -0,0 +1,70 @@ +client_body_timeout 5s; +client_header_timeout 5s; + +location /robots.txt { + default_type text/plain; + + return 200 'User-agent: *\nDisallow: /\n'; +} + +location = /auth { + internal; + + proxy_set_header Content-Length ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Allow-OPTIONS "TRUE"; + # proxy_set_header X-Allow-Web-Font "TRUE"; + + proxy_http_version 1.1; + + proxy_pass_request_body off; + proxy_pass http://js_challenge_backend/auth; +} + +location /header.html { + internal; + + default_type text/html; + + root /var/www/html/captcha/; + + try_files /header.html =503; +} + +location /footer.html { + internal; + + default_type text/html; + + root /var/www/html/captcha/; + + try_files /footer.html =503; +} + +location @js_challenge { + internal; + + proxy_hide_header Content-Type; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-LiteTemplate "TRUE"; + # proxy_set_header X-Allow-OPTIONS "TRUE"; + # proxy_set_header X-Allow-Web-Font "TRUE"; + # proxy_set_header X-JS-Hash-Library-URL "https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js"; + + add_header Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0"; + + # add_before_body /header.html; + # add_after_body /footer.html; + # addition_types "*"; + + proxy_http_version 1.1; + + proxy_pass http://js_challenge; +} diff --git a/nginx/vhost.conf b/nginx/vhost.conf new file mode 100644 index 0000000..e89cd7a --- /dev/null +++ b/nginx/vhost.conf @@ -0,0 +1,13 @@ +include /usr/share/doc/nginx-js-challenge/js_challenge_main.conf; + +server { + listen 80; + server_name domain.net; + + include /usr/share/doc/nginx-js-challenge/js_challenge_server.conf; + + location / { + include /usr/share/doc/nginx-js-challenge/js_challenge_include.conf; + # ... + } +} diff --git a/random.go b/random.go new file mode 100644 index 0000000..c11d764 --- /dev/null +++ b/random.go @@ -0,0 +1,15 @@ +package main + +import ( + "math/rand" + "strconv" +) + +// genNewNonce generates new cryptographic nonce +func genNewNonce() string { + mu.Lock() + number := strconv.Itoa(rand.Intn(maxNonce)) //nolint: gosec + mu.Unlock() + + return number +} diff --git a/revive.toml b/revive.toml new file mode 100644 index 0000000..0153141 --- /dev/null +++ b/revive.toml @@ -0,0 +1,55 @@ +ignoreGeneratedHeader = false +severity = "warning" +confidence = 0.8 +errorCode = 0 +warningCode = 0 + +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.dot-imports] +[rule.error-return] +[rule.error-strings] +[rule.error-naming] +[rule.exported] +[rule.if-return] +[rule.increment-decrement] +[rule.var-naming] +[rule.var-declaration] +[rule.package-comments] +[rule.range] +[rule.receiver-naming] +[rule.time-naming] +[rule.unexported-return] +[rule.indent-error-flow] +[rule.errorf] +[rule.empty-block] +[rule.superfluous-else] +[rule.unused-parameter] +[rule.unreachable-code] +[rule.redefines-builtin-id] + +[rule.argument-limit] + arguments = [6] +[rule.confusing-naming] +[rule.get-return] +[rule.modifies-parameter] +[rule.confusing-results] +[rule.deep-exit] +[rule.flag-parameter] +[rule.unnecessary-stmt] +[rule.struct-tag] +[rule.modifies-value-receiver] +[rule.constant-logical-expr] +[rule.bool-literal-in-expr] +[rule.function-result-limit] + arguments = [4] +[rule.range-val-in-closure] +[rule.waitgroup-by-value] +[rule.atomic] +[rule.empty-lines] +[rule.call-to-gc] +[rule.duplicated-imports] +[rule.import-shadowing] +[rule.bare-return] +[rule.unused-receiver] diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..0138efe --- /dev/null +++ b/templates.go @@ -0,0 +1,224 @@ +package main + +/* + - https://projects.lukehaas.me/css-loaders/ + - https://jshint.com/ + + - https://cdnjs.com/libraries/js-sha1 + - https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js + + - https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST + - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +*/ + +const challengeHTMLTemplate = ` + + + + + + + Loading... + + + + + + + + + +
+
+
+ + +` + +const challengeLightHTMLTemplate = ` + + + + + +` diff --git a/uuid.go b/uuid.go new file mode 100644 index 0000000..5bc5f17 --- /dev/null +++ b/uuid.go @@ -0,0 +1,25 @@ +package main + +import ( + "crypto/rand" + "fmt" + "strings" +) + +// genUUID generates UUIDv4 (random). +func genUUID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + + // this make sure that the 13th character is "4" + b[6] = (b[6] | 0x40) & 0x4F + // this make sure that the 17th is "8", "9", "a", or "b" + b[8] = (b[8] | 0x80) & 0xBF + + // assemble UUIDv4 + uuid := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) + + return strings.ToLower(uuid), nil +}