let's go를 배워보자 10일차 - Security Improvements

0

lets-go

목록 보기
10/15

Security Improvements

이제 우리의 data가 전송 중에 안전을 보장받도록 하고 DoS(denial of service) 공격을 잘 처리할 수 있도록 만들어보자.

1. Generating a Self-Signed TLS Certificate

HTTPS은 기본적으로 TLS(Transport Layer Security) connection을 통해 HTTP로 전송된다. TLS connection을 통해 전송되기 때문에 data는 암호화되고 서명(signed)된다. 이는 전송 중에 privacy와 integrity(무결성)을 보장하는 데 도움을 준다.

만약 이러한 용어에 익숙하지 않다면 TLS은 사실 기본적으로 SSL(Secure Sockets Layer)의 modern version이다. SSL은 현재 공직적으로 보안상의 문제로 deprecated되지만, 그 이름만은 대중 의식안에서 아직 살아있으며 종종 TLS와 상호 운용적으로 사용된다.

우리의 서버가 HTTPS로 시작하기 이전에 TLS certificate를 생성할 필요가 있다.

production server에서 추천하는 것은 TLC certificates를 생성하기 위해서 Let's Encrypt를 사용하여 TLS certificates를 만드는 것이다. 그러나 개발 단계에서는 self-signed certificate만 만들어도 된다.

self-signed certificate은 단지 믿을만한 certificate authority에 의해 암호학적으로 사인되었다는 보장이 없다는 것말고는 일반적인 TLS certificate와 같다. 이는 우리의 브라우저가 처음에는 이러한 보안 sign은 사용된 적이 없다고 warning을 발생시킬 것이다. 그러나 이와 상관없이 HTTPS traffic을 정확히 암호화할 것이고, testing과 개발 목적이면 상관이 없다.

편리하게도 go의 standard library은 crypto/tls package는 generate_cert.go 툴을 포함하여 쉽게 자기 자신이 만든 certificate를 만들 수 있다.

tls라는 폴더를 만들고 여기에 certificate를 담도록 하자.

mkdir tls
cd tls

generate_cert.go 툴을 동작시키기 위해서 go standard lib이 현재 컴퓨터의 어디에 저장되어 있는 지를 알아야 한다. 만약 linux, macOS를 사용하고 있다면 /usr/local/go/src/crypto/tls에 있을 것이다.

만약 macOS를 사용하고 go를 HomeBrew를 이용하여 설치하였다면 파일은 아마 /usr/local/Cellar/go/<version>/libexec/src/crypto/tls/generate_cert.go 또는 이와 비슷한 파일 경로에 있을 것이다.

위치만 알게된다면 이를 이용해 다음과 같이 파일을 만들 수 있다.

go run /usr/local/go/src/crypto/tls/generate_cert.go --rsa-bits=2048 --host=localhost
2023/01/05 12:13:20 wrote cert.pem
2023/01/05 12:13:20 wrote key.pem

certificate가 tls directory에 만들어 졌을 것이다.

generate_cert.go 툴은 두 가지 stage로 동작한다.

  1. 처음에는 2048bit의 RSA key pair를 만들어내는데, 이는 암호학적으로 secure한 public key, private key이다.
  2. 그리고 나서 private key를 key.pem 파일에 저장하고 self-signed TLS certificate를 host인 localhost에 public key를 담아 만들어준다. 이는 cert.pem 파일에 저장된다. private key와 certificate는 PEM으로 인코딩되어 있는데 이는 TLS 구현에 사용되는 standart format이다.

이제 우리는 self-signed TLS certificate를 갖게 되었고, 이에 상응하는 private key도 갖게 되었다.

2. Running a HTTPS Server

이제 HTTPS 웹 서버를 구성해보자. 이는 매우 간단한데, main.go 파엘에서 srv.ListenAndServe() 메서드를 srv.ListenAndServeTLS()로 대신 바꾸어 주면 된다.

main.go 파일을 다음과 같이 변경해준다.

  • cmd/web/main.go
func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	secret := flag.String("secret", "s6Ndh+pPbnzHbS*+9Pk8qGWhTzbpa@ge", "Secret key")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	templateCache, err := newTemplateCache("./ui/html/")
	if err != nil {
		errorLog.Fatal(err)
	}

	session := sessions.New([]byte(*secret))
	session.Lifetime = 12 * time.Hour
	session.Secure = true // Set the Secure flag on out session cookies

	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		session:       session,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
	}

	srv := &http.Server{
		Addr:     *addr,
		ErrorLog: errorLog,
		Handler:  app.routes(),
	}

	infoLog.Printf("Starting server on %s", *addr)
	// Use the ListenAndServeTLS() method to start the HTTPS server. We
	// pass in the paths to the TLS certificate and corresponding private key as
	// the two parameters.
	err = srv.ListenAndServeTLS("./tls//cert.pem", "./tls/key.pem")
	errorLog.Fatal(err)
}

https를 사용하므로 session에도 secure 옵션을 추가하도록 하자. session.Secure = true을 해주고 err = srv.ListenAndServeTLS("./tls//cert.pem", "./tls/key.pem")에 certificate와 private key를 넣어주면 된다.

이제 실행해보고 다음의 요청을 브라우저에 보내면, 신뢰하지 않은 페이지가 나오게 된다.

https://localhost:4000

Confirm Security Exception을 누르면 페이지에 접속할 수 있게 된다.

이제 https 요청이 아닌 http요청이 오면 유저에게 400 Bad Request응답이 오게되고 메시지는 Client sent an HTTP request to an HTTPS server라고 온다.

HTTPS를 쓰게되면 가장 큰 장점은, 만약 client가 HTTP/2 connections을 지원하면 go의 https server는 자동으로 connection을 http/2를 사용하도록 업그레이드 해준다.

이는 궁극적으로 우리의 페이지들이 user에게 매우 빠르게 로딩된다는 장점이 있다. 만약 http/2에 대해서 잘 모른다면 다음의 페이지 링크를 확인해보도록 하자.

추가적으로 우리가 만든 tls directory에 있는 파일들은 github와 같은 공개 repo에 올려지지 않은 것이 좋다.

3. Configuring HTTPS Settings

go는 https 서버에 대한 몇 가지 좋은 default setting들을 제공한다. 그러나 여기에는 몇 가지 optimizations와 customizations가 필요하다.

이전에 https와 tls가 어떻게 동작하는 지를 모르는 사람들을 위해 간단한 설명을 하면 다음과 같다.

TLS connection은 두 가지 stage로 나뉜다고 생각하면 된다. 첫번재 stage는 handshake를 하는 단계로 client가 해당 server가 믿을 만 한지 검증을 하고, TLS session key들을 만들어낸다. 두번째 단계는 data의 실제 전송이다. 이는 data가 암호화되고 사인(signed, 보장)되는데, handshake 단계에서 만들어진 TLS session key를 사용한다.

요약하자면 다음과 같이 동작한다.

  1. TCP connection은 만들어지고 TLS handshake가 시작된다. 클라이언트는 web server에 TSL version 및 암호 모음 리스트(목록)을 전달한다. cipher suites(암호 모음)은 기본적으로 connection이 사용하고 있는 서로 다른 암호학적 알고리즘을 설명하는 식별자이다.

  2. web server는 client에게 선택한 TLS version 및 암호 목록(cipher suite)의 confirmation을 전달한다. 여기서 암호 목록(cipher suite)에 따라 긴밀한 과정이 달라지는데, 설명을 위해서 cipher suite를 TLS_RSA_WITH_AES_128_GCM_SHA256을 사용했다고 하자.

  3. cipher suite의 첫번째 부분은 사용된 key교환 알고리즘을 나타낸다. (우리의 경우는 RSA 암호 알고리즘을 사용) web server는 이에 대한 TLS certification을 전달한다. 우리의 경우 이 certification에 RSA public key가 있다. 클라이언트는 TLS certificate가 만료되었는 지, 또는 믿을 만 한지에 대해서 검증을 한다. web browser는 주요 certificate authorities의 public key에 대해서 설치되어 있는데, 이를 사용하여 web server의 certificate가 정말로 믿을 만한 certificate authority에 의해 사인되었는 지를 검증한다. 클라이언트는 또한 TLS에 certificate에 서명된 host가 open connection을 가지는 host인지에 확인한다.

  4. client는 secret session key를 만드는데, 클라이언트는 server의 cipher suites 알고리즘에서 public key(우리의 경우 certificate의 RSA public key)를 사용하여 secret session key를 암호화한 뒤 private key를 사용하여 복호화할 수 있는 웹서버에 secret key들을 전송한다. (우리의 경우 RSA private key) 이는 key 교환으로 알려져있는데, client와 web server 둘 다 현재 같은 session key들로 접근이 가능하고 다른 집단이 이를 알기 어렵다. 이것이 TLS handshake의 끝이다.

  5. 이제 실제 데이터의 전송이 준비되었다. 일반적으로 16KB 정도의 사이즈인 record에 실제 data가 나뉘어 들어간다. 클라이언트는 session key들 중 하나를 사용하고 cipher suite(우리의 경우 SHA256)에서 지시하는 메시지 인증 코드 알고리즘을 사용하여 각 record의 HMAC를 계산한다. 그리고 나서 클라이언트는 또 다른 session key와 cipher suite(우리의 경우 AES_128_GCM) bulk 암호화 알고리즘을 사용하여 record를 암호화 한다.

  6. 암호화되고 서명된 record를 server에 전송하고 난 뒤에 session key를 사용하여 server는 record를 복호화하고 서명(signature)가 올바른 지 검사한다.

이 과정에서 두 가지 다른 종류의 암호화가 사용되었다. TLS handshake 부분에서는 비대칭 암호화인 RSA가 안전하게 session key를 공유하기 위해 사용되었고, 실제 데이터를 전송하기 위해서는 대칭 알고리즘인 AES가 사용되어 빠르게 전송되었다.

더 자세한 내용을 알고싶다면 비디오를 확인해보도록 하자.

이제 https settings에 대해서 알아보자. 한 가지 재밌는 것은 거의 대게 항상 TLS handshake에서 사용되는 elliptic curve 암호화 알고리즘(ECC, RSA와 같이 비대칭 암호화 알고리즘이지만 ECC 타원 곡선을 사용하고 RSA은 타원 곡선을 사용하지 않는다.) 사용을 제한하는 것이 좋다는 것이다. go은 몇가지 elliptic curves를 제공하는데, go 1.17에서 오직 tls.CurveP256tls.X25519는 assembly로 구현되었지만, 다른 것들은 매우 CPU intensive하므로 이들을 생략하는 것이 우리의 서버의 성능을 수많은 load들로 부터 효율적으로 만드는 데 큰 도움을 준다.

이를 위해 tls.Config 구조체를 만들고, http.Server 구조체에 설정해주도록 하자.

  • cmd/web/main.go
...
func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	secret := flag.String("secret", "s6Ndh+pPbnzHbS*+9Pk8qGWhTzbpa@ge", "Secret key")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	templateCache, err := newTemplateCache("./ui/html/")
	if err != nil {
		errorLog.Fatal(err)
	}

	session := sessions.New([]byte(*secret))
	session.Lifetime = 12 * time.Hour
	session.Secure = true

	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		session:       session,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
	}
	// Initialize a tls.Config struct to hold the non-default TLS settings we want
	// the server to use. In this case the only thing that we're changing is the
	// curve preferences value, so that only elliptic curves with assembly
	// implementations are used.
	tlsConfig := &tls.Config{
		CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
	}
	// Set the server's TLSConfig field to use the tlsConfig variable we just
	// created.
	srv := &http.Server{
		Addr:      *addr,
		ErrorLog:  errorLog,
		Handler:   app.routes(),
		TLSConfig: tlsConfig,
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServeTLS("./tls//cert.pem", "./tls/key.pem")
	errorLog.Fatal(err)
}
...

tlsConfig을 설정하고 http.Server 객체에 넣어주면 된다.

추가적으로 go에서는 지원하는 cipher suites를 const 변수로 관리하고 있는데 일부 application에서는 원하는 cipher suites가 있어서 이를 https server에서 지원하도록 제한할 수 있다. 가령 오직 ECDHE을 지원하고 약한 암호화 알고리즘을 제공하는 RC4, 3DES, CBC는 사용하지 않도록 할 수 있다. 이는 tls.Configtls.Config.CipherSuites 필드에 설정할 수 있다.

tlsConfig := &tls.Config{
    CipherSuites: []uint16{
        tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
        tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
        tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
        tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
    },
}

go 1.17부터는 자동적으로 system에 맞게 cipher suites를 설정해주므로 너무 신경쓰지 않아도 된다.

또한 TLS 1.3 connection에서는 CipherShites 필드는 무시되는데 이유는 go에서는 TLS 1.3 connection들을 안전하다고 여겨 불필요한 configuration을 설정하지 않는다. 따라서 tls.Config로 custom한 cipher suites를 설정하는 것은 오직 TLS 1.0과 TLS.1.2 connections에 유효하다.

TLS version은 또한 go의 crypto/tls 패키지에서 상수로 정의되어 있는데, 1.0~1.3까지 전부 지원한다.

tls.Config.MinVersionMaxVersion 필드를 설정하여 TLS version을 설정할 수 있는데, 만약 TLS 1.2만 설정하고, 1.3은 설정하고 싶지않다면 다음과 같이 할 수 있다.

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS12,
    MaxVersion: tls.VersionTLS12,
}

4. Connection Timeouts

timeout setting을 추가하여 우리의 server에 resiliency를 향상시켜보자

  • cmd/web/main.go
...
func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	secret := flag.String("secret", "s6Ndh+pPbnzHbS*+9Pk8qGWhTzbpa@ge", "Secret key")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	templateCache, err := newTemplateCache("./ui/html/")
	if err != nil {
		errorLog.Fatal(err)
	}

	session := sessions.New([]byte(*secret))
	session.Lifetime = 12 * time.Hour
	session.Secure = true

	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		session:       session,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
	}

	tlsConfig := &tls.Config{
		CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
	}

	srv := &http.Server{
		Addr:      *addr,
		ErrorLog:  errorLog,
		Handler:   app.routes(),
		TLSConfig: tlsConfig,
		// Add Idle, Read and Write timeouts to the server.
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServeTLS("./tls//cert.pem", "./tls/key.pem")
	errorLog.Fatal(err)
}
...

추가한 것이라고는 IdleTimeout, ReadTimeout, WriteTimeout이거 3개 밖에 없지만 이는 기저의 connection에 모두 동작하고 모든 request에 URL또는 핸들러에 관계없이 동작한다.

5. IdleTimeout

기본적으로 go는 모든 accepted connection들에 대해서 keep-alives을 가능하게 해두었다. 이는 latency(특히 HTTPS connections에 대한)를 감소시키는데, 클라이언트가 계속해서 handshake를 할 필요없이 여러 개의 request들에 대해서 같은 connection을 재사용할 수 있도록 하기 때문이다. 즉, receive, send 모두 같은 http connection을 사용할 수 있기 때문이다.

또한, 기본적으로 go는 자동적으로 keep-alive connections이 3분 동안 inactivity에 있으면 close시켜준다. 이는 유저가 예기치 않게 사라진 connection들에 대해 정리가 가능하다. (가령, user의 pc power가 갑자기 off되었을 때)

3분 보다 더 높게 cut-off시간을 늘릴 수는 없다. (단, net.Listener를 다시 만들면 가능할 수도 있다.) 하지만 inactivity에 있는 connection을 3분 보다 적은 시간 동안 있으면 connection을 단절시키는 것은 가능하다. 그것이 바로 IdleTimeout이다. 우리의 경우 이를 1분으로 설정하였다. 이는 모든 keep-alive connection은 inactivity로 1분 동안 있으면 자동적으로 닫힌다는 것이다.

6. ReadTimeout

우리의 코드에서 우리는 또한 ReadTimeout을 5초로 설정하였다. 이는 만약 request header 또는 request body가 첫번째 request가 허용된 후, 여전히 읽힌 지 5초가 흘렀다면, go가 기저의 connection을 닫을 것이라는 것이다. 이는 connection을 하드하게 닫는 것이므로 user은 http response를 받을 수 없을 수 있다.

ReadTimeout을 짧게 설정하는 것은 slow-client attacks 가령 Sloworis와 같은 공격으로부터의 위험을 처리하는데 큰 도움을 준다. 만약, 이렇게 설정하지 않으면 불완전하고 파편적인 http(s) 요청을 전송함으로서 connection을 영원히 open시켜버릴 수 있다.

중요하게도 만약 ReadTimeout은 설정하였는데, IdleTimeout을 설정하지 않았다면 IdleTimeoutReadTimeout과 동일한 setting이 기본적으로 설정될 것이다. 가령, 만약 ReadTimeout을 3초로 설정하였다면, 모든 keep-alive connection들이 inactivity 후 3초가 지나면 close되는 문제가 발생할 수 있다. 일반적으로 이러한 문제를 해결하기 위해 IdleTimeout을 명시적으로 설정하는 것이 좋다.

7. WriteTimeout

WriteTimeout setting은 만약 우리의 서버가 주어진 period(우리의 코드는 10초) 후에도 connection에 쓰기 연산을 실행하려고 하면 connection을 닫는다. 그러나 이 행동은 약간은 어떤 protocol을 쓰냐에 따라 다르다.

  • HTTP connections에 대해서는 만약 request header의 read가 끝나고, 일부 데이터가 connection에 10초 후에 쓰인다면, go는 data를 쓰기보다는 connection을 닫는다.

  • HTTPS connections에 대해서는 만약 일부 데이터가 request가 처음 받아들여지고 난 뒤 10초 후에 connection에 write 연산을 한다면 go는 기저에 write연산을 하지않고 connection을 닫는다. 이는 HTTPS에서는 WriteTimeout의 값을 ReadTimeout 값보다 더 크게 설정하는 것이 맞다.

9. ReadHeaderTimeout

우리가 설정하지 않았지만, ReadHeaderTimeout도 있다. 이는 ReadTimeout과 동일한 동작을 하는데 오직 HTTP(s) hader에만 적용된다는 점을 제외하고는 동일하다. 그래서 만약 ReadHeaderTimeout을 3초로 설정하면 connection은 request header가 처음 받아들여진 이후로 3초 동안 read하고 있으면 connection을 끊어버린다. 그러나, request body는 적용되지 않아서 3초 동안 읽고 있어도 connection이 닫히지 않는다.

10. MaxHeaderBytes

http.Server 객체는 또한 MaxHeaderBytes 필드를 제공하는데 이는 request haeder를 파싱할 때, server가 읽을 최대 바이트 수를 제한한다. 기본적으로 go는 maximum header의 길이를 1MB로 제한한다.

만약 maximum header의 길이를 0.5MB로 줄이고 싶다면 다음과 같이 쓸 수 있다.

srv := &http.Server{
    Addr: *addr,
    MaxHeaderBytes: 524288,
    ...
}

만약 MaxHeaderBytes를 넘기면 user는 자동적으로 431 Request Header Fields Too Large 응답을 받게 된다.

물론 go는 추가적으로 4096bytes를 설정해놓기 때문에, 이점을 고려해야한다.

0개의 댓글