Last updated at Wed, 10 May 2023 19:06:14 GMT
We all know security is hard. Let’s walk through some basic security principles you can use to get your Golang web application up and running securely. If you just want to see the code check out the application on Github: Golang Secure Example Application (gosea).
Recently, I gave a lightning talk on using Golang middleware to implement some basic security controls at the Boston Golang Meetup. This post will include some of those concepts and expand upon topics not covered in the talk. Another great starting point without rolling some of this stuff yourself is to use the unrolled/secure package, which has a ton of great features.
Disclaimer: These are some good practices to follow and there are plenty of other ways your application can be insecure. This post does not make any guarantees for complete security for your application.
Serve over HTTPS
The first thing I think about when starting a dynamic web application is serving it over HTTPS. There are arguments that not everything needs to be served over HTTPS, but here we are securing a web application that has login or is backed by some information that may be sensitive.
Before we write any code we want to generate our keys that we will use and place them in our root directory. In production you probably want to put the cert in something like /root/certs and keys in something like /etc/ssl/private.
First generate the private key:
openssl genrsa -out server.key 2048
openssl ecparam -genkey -name secp384r1 -out server.key
Then generate the x509 self-signed public key:
openssl req -new -x509 -sha256 -key server.key -out server.pem -days 3650
Now we can create our application in a main.go:
package main
import (
"log"
"net/http"
"github.com/komand/gosea/services"
)
func main() {
certPath := "server.pem"
keyPath := "server.key"
api := NewAPI(certPath, keyPath)
http.Handle("/hello", api.Hello)
err := http.ListenAndServeTLS(":3000", certPath, keyPath, nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
Note: The API structure is left out of this discussion, but can be found in the gosea repo.
Then we create two directories called handlers and services that represent the various layers of our applications. We separate them for testability at the different layers.
Let’s create a hello.go in each:
handlers/hello.go
package handlers
import (
"net/http"
"github.com/komand/gosea/services"
)
// Hello exposes an api for the hello service
type Hello struct {
Service services.HelloService
}
// NewHello creates a new handler for hello
func NewHello(s services.HelloService) *Hello {
return &Hello{s}
}
// Handler handles hello requests
func (h *Hello) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "GET":
s := h.Service.SayHello()
w.Write([]byte(s))
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
}
services/hello.go
package services
import "net/http"
// HelloService provides a SayHello method
type HelloService interface {
SayHello() string
}
// NewHelloService creates a new hello world service
func NewHelloService() HelloService {
return &helloService{}
}
type helloService struct {}
// SayHello says hello for the hello service
func (h *helloService) SayHello() string {
return "hello, world!"
}
Before we can boot up the application, we first must install it with go install. Then run gosea. We can now access our application at https://localhost:3000/hello. You should get a warning if you are using Chrome, but it’s only because we are using a self-signed certificate. In production you would want to use a certificate from a legitimate CA. If you continue through, it should render “hello, world!” in the browser.
Authenticate Your Users
Once we are serving our application over HTTPS, the next thing we should think about is ensuring only authenticated users are allowed into our application. Authentication defines whether a user is admitted into your system and is the first barrier to someone gaining access to your application. Before we authenticate you must have users (the code for the users handler/service can be found at the gosea repo on github).
In Go, authentication can be implemented relatively simply with JSON Web Tokens (JWT) using an authentication endpoint and middleware. There are great articles on how to do that by Auth0 and Brainattica, but let’s walk through the exercise in a similar vein.
Let’s implement a tokens service. We can edit our main function to add a /tokens route:
http.Handle("/tokens", api.Tokens.Handler)
Now let’s add a token.go file to the handlers package:
package handlers
import (
"net/http"
"github.com/komand/gosea/services"
)
// Tokens exposes an API to the tokens service
type Tokens struct {
Service services.TokenService
}
// NewTokens creates new handler for tokens
func NewTokens(s services.TokenService) *Tokens {
return &Tokens{s}
}
// ServeHTTP will return tokens
func (t *Tokens) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "GET":
// TODO: Take in login information
user := &services.User{
ID: 1,
FirstName: "Admin",
LastName: "User",
Roles: []string{services.AdministratorRole},
}
token, err := t.Service.Get(user)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
}
w.Write([]byte(token))
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
}
And a token.go file to the services package:
package services
import (
"errors"
"time"
"github.com/dgrijalva/jwt-go"
)
// Set our secret.
// TODO: Use generated key from README
var mySigningKey = []byte("secret")
// Token defines a token for our application
type Token string
// TokenService provides a token
type TokenService interface {
Get(u *User) (string, error)
}
type tokenService struct {
UserService UserService
}
// NewTokenService creates a new UserService
func NewTokenService() TokenService {
return &tokenService{}
}
// Get retrieves a token for a user
func (s *tokenService) Get(u *User) (string, error) {
// Create token
token := jwt.New(jwt.SigningMethodHS256)
// Try to log in the user
user, err := s.UserService.Read(u.ID)
if err != nil {
return "", errors.New("Failed to retrieve user")
}
if user == nil {
return "", errors.New("Failed to retrieve user")
}
// Set token claims
token.Claims["admin"] = true
token.Claims["user"] = u
token.Claims["exp"] = time.Now().Add(time.Hour * 24).Unix()
// Sign token with key
tokenString, err := token.SignedString(mySigningKey)
if err != nil {
return "", errors.New("Failed to sign token")
}
return tokenString, nil
}
Once the token is issued, we can then implement middleware on our API that will wrap our handlers and authenticate users using jwt.
// Authenticate provides Authentication middleware for handlers
func (a *API) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var token string
// Get token from the Authorization header
// format: Authorization: Bearer
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
// If the token is empty...
if token == "" {
// If we get here, the required token is missing
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
// Now parse the token
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
return nil, msg
}
return a.encryptionKey, nil
})
if err != nil {
http.Error(w, "Error parsing token", http.StatusUnauthorized)
return
}
// Check token is valid
if parsedToken != nil && parsedToken.Valid {
// Everything worked! Set the user in the context.
context.Set(r, "user", parsedToken)
next.ServeHTTP(w, r)
fmt.Println("test")
}
// Token is invalid
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
})
}
Authorize Your Users
What happens after users are logged into your system? You probably have various roles for your application Admin, User, etc. Do they all have access to everything in your application? I doubt it. This is where authorization comes in. I find this is best done as middleware, similar to authentication. Details about the AclService that this middleware uses can be found in the repo.
// Authorize provides authorization middleware for our handlers
func (a *API) Authorize(permissions ...services.Permission) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Get User Information from Request
user := &services.User{
ID: 1,
FirstName: "Admin",
LastName: "User",
Roles: []string{services.AdministratorRole},
}
for _, permission := range permissions {
if err := a.AclService.CheckPermission(user, permission); err != nil {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}
Now if you hit the /users endpoint, you should get an Unauthorized message.
Use Secure Headers
Let’s create a shell middleware for our secure headers:
// SecureHeaders adds secure headers to the API
func (a *API) SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// We will add our headers here
next.ServeHTTP(w, r)
})
}
Allowed Hosts
Allowed Hosts header provides a list of fully qualified domain names (FQDN) that are allowed to serve your site. This prevents cache poisoning and ensures random domains cannot be pointed at your site.
var err error
if len(a.AllowedHosts) > 0 {
isGoodHost := false
for _, allowedHost := range a.options.AllowedHosts {
if strings.EqualFold(allowedHost, r.Host) {
isGoodHost = true
break
}
}
if !isGoodHost {
a.errorHandler.ServeHTTP(w, r)
err = fmt.Errorf("Bad host name: %s", r.Host)
}
}
// If there was an error, do not continue request
if err != nil {
http.Error(w, “Failed to check allowed hosts”, http.StatusInternalServerError)
}`
X-XSS-Protection
Set it to “1; mode=blockFilter” enabled. Rather than sanitize the page, when a XSS attack is detected, the browser will prevent rendering of the page.
// Add X-XSS-Protection header
w.Header().Add(xssProtectionHeader, xssProtectionValue)
Content-Type
Content type tells the browser what type of content you are sending. If you do not include it, the browser will try to guess the type and may get it wrong.
// Add Content-Type header
w.Header().Add("Content-Type", "application/json")
Based on the type of application, you may need to add this header to your handlers (if you are returning other types of data), but since this is just an API that will return JSON, it is OK for now. Additionally, many frameworks like gin handle this for you.
X-Content-Type-Options
Content Sniffing is the inspecting the content of a byte stream to attempt to deduce the file format of the data within it. Browsers will do this to try to guess at the content type you are sending. By setting this header to “nosniff”, it prevents IE and Chrome from content sniffing a response away from its actual content type. This reduces exposure to drive-by download attacks.
// Add X-Content-Type-Options header
w.Header().Add("X-Content-Type-Options", "nosniff")
X-Frame-Options
// Prevent page from being displayed in an iframe
w.Header().Add("X-Frame-Options", "DENY")
Add the Middleware to Handlers
Finally, we add the middleware to our handlers so that they can use the features we just implemented. Let’s define a function that will add all our middleware for our handlers.
// AddMiddleware adds middleware to a Handler
func AddMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for _, mw := range middleware {
h = mw(h)
}
return h
}
Now for an example, we can add authentication and authorization to users:
http.Handle("/users", AddMiddleware(api.Users,
api.Authenticate,
api.Authorize(services.Permission("user_modify")),
api.SecureHeaders,
))
More Features!
In the next part of this series, I will describe and implement some additional security practices into gosea. These are primarily for use cases in the front end of an application and thus have not been covered with these backend topics, including using secure cookies and preventing cross site request forgery.