Browser modern menerapkan same-origin policy: JavaScript pada sebuah halaman hanya boleh membaca respons dari origin yang sama dengan halaman tersebut. Kebijakan ini melindungi pengguna dari situs jahat yang mencoba membaca data sensitif lewat permintaan diam-diam. Aplikasi modern — misalnya frontend di https://app.example.com yang memanggil API di https://api.example.com — justru membutuhkan akses lintas origin yang sah. CORS (Cross-Origin Resource Sharing) adalah mekanisme HTTP yang membuka akses tersebut secara terkendali melalui header respons. Pada chapter ini kita akan membedah konsep origin, alasan browser mengirim preflight OPTIONS, daftar header CORS yang benar, lalu praktik menulis middleware CORS di Go dengan httptest.

Origin dan Host: Dua Hal yang Berbeda #

Origin adalah gabungan tiga bagian: scheme, host, dan port. URL https://app.example.com/beranda memiliki origin https://app.example.com; path tidak dihitung, sedangkan scheme, host, dan port harus sama persis. Tabel berikut membandingkan beberapa URL terhadap origin https://app.example.com:

URLOrigin sama?Penyebab
https://app.example.com/profilYaHanya path yang berbeda
http://app.example.com/berandaTidakScheme berbeda (http vs https)
https://api.example.com/berandaTidakHost berbeda
https://app.example.com:8443/berandaTidakPort berbeda (8443 vs 443 implisit)

Header Host pada permintaan HTTP menunjukkan server tujuan permintaan, sedangkan header Origin yang dikirim browser menunjukkan halaman asal permintaan. Server membaca header Origin untuk memutuskan apakah JavaScript dari halaman tersebut boleh membaca respons. Oleh karena itu, server membandingkan header Origin terhadap daftar origin yang dipercaya.

Mengapa Browser Mengirim Preflight OPTIONS #

Permintaan “sederhana” seperti GET tanpa header khusus langsung dikirim ke server, lalu keputusan baca/tolak dilakukan setelah respons tiba. Permintaan yang tidak sederhana — misalnya method PUT atau DELETE, header kustom seperti Authorization, atau Content-Type: application/json — terlebih dahulu memicu preflight: browser mengirim request OPTIONS dengan header Access-Control-Request-Method dan Access-Control-Request-Headers sebagai pertanyaan izin sebelum permintaan sungguhan dikirim.

Server menjawab preflight dengan status 204 No Content beserta header Access-Control-Allow-Methods, Access-Control-Allow-Headers, dan Access-Control-Max-Age. Nilai Max-Age dalam detik menentukan berapa lama browser boleh menyimpan jawaban preflight, sehingga preflight tidak dikirim berulang-ulang. Karena preflight bukan bagian logika aplikasi, jawab preflight di middleware tanpa menyentuh database.

Header CORS yang Perlu Disetel #

Header responsFungsiContoh nilai
Access-Control-Allow-OriginOrigin mana yang boleh membaca responshttps://app.example.com
Access-Control-Allow-MethodsMethod yang diizinkan untuk origin tersebutGET, POST, PUT, DELETE
Access-Control-Allow-HeadersHeader request yang boleh dikirimContent-Type, Authorization
Access-Control-Allow-CredentialsMengizinkan cookie dikirim lintas origintrue
Access-Control-Max-AgeUmur cache jawaban preflight (detik)600

Middleware CORS di Go #

CORS paling rapi diimplementasikan sebagai middleware: function yang membungkus http.Handler dan menyetel header izin sebelum handler berikutnya dipanggil. Program berikut memakai httptest untuk mensimulasikan tiga permintaan tanpa membuka port jaringan: preflight OPTIONS, GET dari origin yang diizinkan, dan GET dari origin asing:

go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

// allowedOrigin adalah satu-satunya origin yang dipercaya server.
const allowedOrigin = "https://app.example.com"

// corsMiddleware menyetel header CORS dan menjawab preflight OPTIONS.
func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		if origin == allowedOrigin {
			// Nilai spesifik, bukan "*": aman dipakai bersama credentials.
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Add("Vary", "Origin")
		}
		if r.Method == http.MethodOptions {
			// Preflight: browser menanyakan izin sebelum request sungguhan.
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Max-Age", "600")
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// cetakHeader mencetak satu header respons, atau "(tidak diset)" bila kosong.
func cetakHeader(rec *httptest.ResponseRecorder, key string) {
	v := rec.Header().Get(key)
	if v == "" {
		v = "(tidak diset)"
	}
	fmt.Println(key+":", v)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/salam", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Halo dari API")
	})
	handler := corsMiddleware(mux)

	// Skenario 1: preflight OPTIONS dari origin yang diizinkan.
	req := httptest.NewRequest(http.MethodOptions, "/api/salam", nil)
	req.Header.Set("Origin", allowedOrigin)
	req.Header.Set("Access-Control-Request-Method", "POST")
	req.Header.Set("Access-Control-Request-Headers", "Content-Type")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println("== Skenario 1: preflight OPTIONS ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	cetakHeader(rec, "Access-Control-Allow-Methods")
	cetakHeader(rec, "Access-Control-Allow-Headers")
	cetakHeader(rec, "Access-Control-Max-Age")
	cetakHeader(rec, "Vary")

	// Skenario 2: GET biasa dari origin yang diizinkan.
	req = httptest.NewRequest(http.MethodGet, "/api/salam", nil)
	req.Header.Set("Origin", allowedOrigin)
	rec = httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println()
	fmt.Println("== Skenario 2: GET dari origin diizinkan ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	fmt.Println("body:", strings.TrimSpace(rec.Body.String()))

	// Skenario 3: GET dari origin asing — header izin tidak diset.
	req = httptest.NewRequest(http.MethodGet, "/api/salam", nil)
	req.Header.Set("Origin", "https://jahat.example.net")
	rec = httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println()
	fmt.Println("== Skenario 3: GET dari origin asing ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	fmt.Println("body tetap terkirim ke browser, namun JavaScript diblokir")
}

Output yang Dihasilkan:

text
== Skenario 1: preflight OPTIONS ==
status: 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Vary: Origin

== Skenario 2: GET dari origin diizinkan ==
status: 200
Access-Control-Allow-Origin: https://app.example.com
body: Halo dari API

== Skenario 3: GET dari origin asing ==
status: 200
Access-Control-Allow-Origin: (tidak diset)
body tetap terkirim ke browser, namun JavaScript diblokir

Mari kita telaah baris demi baris program di atas:

  1. Baris 14-16 (corsMiddleware): function menerima handler berikutnya (next) lalu mengembalikan handler baru; baris 16 membaca header Origin dari setiap request yang lewat.
  2. Baris 17-21: hanya request dengan Origin yang cocok dengan allowedOrigin (baris 11) yang mendapat header Access-Control-Allow-Origin bernilai spesifik, ditambah Vary: Origin untuk cache perantara.
  3. Baris 22-29: preflight OPTIONS dijawab langsung dengan status 204, header izin, lalu return — permintaan tidak pernah diteruskan ke mux maupun database.
  4. Baris 45 (mux.HandleFunc("GET /api/salam", ...)): pattern berbasis method (sejak Go 1.22) mendaftarkan handler API; OPTIONS tidak pernah sampai ke mux karena sudah dicegat middleware.
  5. Baris 51-56 (httptest.NewRequest dan httptest.NewRecorder): membuat request dan perekam respons palsu; handler.ServeHTTP(rec, req) menjalankan seluruh rantai middleware tanpa jaringan sungguhan.
  6. Baris 78-88 (skenario 3): origin asing tetap menerima respons 200 beserta body, namun header izin tidak diset — yang memblokir akses adalah browser, bukan server.

Skenario 3 pada output di atas menunjukkan sifat CORS yang sering disalahpahami: CORS bukan mekanisme autentikasi. curl atau skrip penyerang tidak menjalankan same-origin policy dan tetap bisa membaca body Halo dari API. Header CORS hanya menginstruksikan browser untuk memblokir JavaScript dari origin asing. Autentikasi dan otorisasi di sisi server tetap wajib, sebagaimana dibahas pada chapter Token-based Authentication .

Atribut SameSite pada cookie mengatur kapan browser mengirim cookie lintas situs, sedangkan CORS mengatur kapan JavaScript boleh membaca respons lintas origin. Cookie SameSite=None; Secure hanya terbaca pada aplikasi lintas origin bila server juga menyetel Access-Control-Allow-Credentials: true dan origin spesifik. Detail SameSite dibahas pada chapter Cookie Security dan pencegahan permintaan palsu antarsitus pada chapter CSRF .

Referensi #

Dengan middleware CORS, Vary: Origin, dan origin spesifik, API kita dapat diakses frontend lintas origin secara aman. Chapter berikutnya membahas celah keamanan di lapisan yang berbeda: SQL injection — serangan yang mengeksploitasi cara kita menyusun teks query.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

cors-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

// allowedOrigin adalah satu-satunya origin yang dipercaya server.
const allowedOrigin = "https://app.example.com"

// corsMiddleware menyetel header CORS dan menjawab preflight OPTIONS.
func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		if origin == allowedOrigin {
			// Nilai spesifik, bukan "*": aman dipakai bersama credentials.
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Add("Vary", "Origin")
		}
		if r.Method == http.MethodOptions {
			// Preflight: browser menanyakan izin sebelum request sungguhan.
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Max-Age", "600")
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// cetakHeader mencetak satu header respons, atau "(tidak diset)" bila kosong.
func cetakHeader(rec *httptest.ResponseRecorder, key string) {
	v := rec.Header().Get(key)
	if v == "" {
		v = "(tidak diset)"
	}
	fmt.Println(key+":", v)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/salam", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Halo dari API")
	})
	handler := corsMiddleware(mux)

	// Skenario 1: preflight OPTIONS dari origin yang diizinkan.
	req := httptest.NewRequest(http.MethodOptions, "/api/salam", nil)
	req.Header.Set("Origin", allowedOrigin)
	req.Header.Set("Access-Control-Request-Method", "POST")
	req.Header.Set("Access-Control-Request-Headers", "Content-Type")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println("== Skenario 1: preflight OPTIONS ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	cetakHeader(rec, "Access-Control-Allow-Methods")
	cetakHeader(rec, "Access-Control-Allow-Headers")
	cetakHeader(rec, "Access-Control-Max-Age")
	cetakHeader(rec, "Vary")

	// Skenario 2: GET biasa dari origin yang diizinkan.
	req = httptest.NewRequest(http.MethodGet, "/api/salam", nil)
	req.Header.Set("Origin", allowedOrigin)
	rec = httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println()
	fmt.Println("== Skenario 2: GET dari origin diizinkan ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	fmt.Println("body:", strings.TrimSpace(rec.Body.String()))

	// Skenario 3: GET dari origin asing — header izin tidak diset.
	req = httptest.NewRequest(http.MethodGet, "/api/salam", nil)
	req.Header.Set("Origin", "https://jahat.example.net")
	rec = httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println()
	fmt.Println("== Skenario 3: GET dari origin asing ==")
	fmt.Println("status:", rec.Code)
	cetakHeader(rec, "Access-Control-Allow-Origin")
	fmt.Println("body tetap terkirim ke browser, namun JavaScript diblokir")
}