Web Artisan
Beranda

Progress belajar

Modul 56 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 7 · Security

Keamanan Input
untuk API Produksi

Input security adalah pagar pertama sebelum auth, database, storage, dan admin backoffice menerima data dari dunia luar.

Bahasa: Go 1.26~70 menit baca
01

Kenapa Input Security Tidak Boleh Belakangan

Di React atau Laravel, kamu mungkin terbiasa punya form validation, sanitizer, middleware, dan request object yang terasa otomatis. Di Go, semuanya lebih eksplisit.

Input security bukan sekadar memastikan field wajib terisi. Fokus modul ini adalah mencegah input berbahaya merusak sistem, mulai dari query database, text field yang nanti tampil di UI, upload gambar produk, request body raksasa, sampai nama file yang mencoba keluar dari folder storage.

🌉Jembatan: Laravel Request::validate vs Go eksplisit

Di Laravel, validasi sering dimulai dari Request::validate atau Form Request. Di Go, handler biasanya membaca input, memanggil fungsi validasi, lalu service tetap memvalidasi business rule. Hasilnya lebih verbose, tetapi batas tanggung jawabnya sangat jelas.

Laravel / PHP
  • Request::validate, middleware, dan ORM membantu banyak guardrail.
  • Query builder biasanya otomatis binding parameter ketika dipakai dengan benar.
  • Upload sering dibungkus oleh object file framework.
Go
  • Handler membaca request dengan net/http lalu validasi ditulis eksplisit.
  • Repository pgx memakai placeholder PostgreSQL seperti $1 dan args terpisah.
  • Upload harus dibatasi ukuran, dicek MIME type, dan disimpan dengan nama buatan server.

Pada proyek online shop skincare, input datang dari banyak arah: customer mencari produk, admin mengubah katalog, customer menulis review, payment gateway mengirim webhook, dan admin mengunggah gambar produk. Setiap pintu punya risiko berbeda.

02

Permukaan Serangan di Online Shop

Sebelum menulis kode, petakan dulu input mana yang bisa menyentuh database, browser, file system, atau resource server.

Search dan filter produk

Keyword, brand, category, sort, dan pagination masuk ke SQL. Risiko utamanya SQL injection dan query mahal.

Review dan profil customer

Text field bisa aman saat disimpan, tetapi menjadi risiko XSS ketika ditampilkan di admin panel, email, atau halaman storefront.

Upload gambar produk

File upload membawa risiko MIME spoofing, ukuran besar, malware, dan nama file berisi path traversal.

Webhook dan form besar

Body request bisa terlalu besar sebelum sempat divalidasi. Server perlu membatasi ukuran sejak awal handler atau middleware.

flowchart TD
  Client["Client atau Admin"] --> API["Go API"]
  API --> Validator["Input validation"]
  Validator --> Handler["Handler"]
  Handler --> Service["Service"]
  Service --> Repo["Repository pgx"]
  Repo --> DB[("PostgreSQL")]
  Handler --> Store["Object storage"]
  Validator --> Reject["Reject 400 atau 413"]

Gambar 1. Input harus melewati validasi sebelum menyentuh database atau storage.

input security

Serangkaian guardrail untuk memastikan data dari luar sistem tidak bisa memanipulasi query, merusak tampilan, membebani server, atau mengakses file di luar area yang diizinkan.

03

Request Validation dengan validator/v10

Sebelum input menyentuh service, validasi dulu bentuknya: field wajib ada, panjang masuk akal, format benar, dan nilai enum sesuai allowlist.

Di React kamu mungkin pakai zod atau express-validator. Di Laravel kamu menulis rules di Form Request. Di Go, padanan paling populer adalah github.com/go-playground/validator/v10: kamu menempelkan aturan sebagai struct tag, lalu satu panggilan memeriksa seluruh struct sekaligus.

🌉Jembatan: zod / express-validator / Form Request ke struct tag

Aturan Laravel required|min:3|max:120 dan skema zod z.string().min(3).max(120) punya padanan langsung di tag validate:“required,min=3,max=120”. Bedanya, di Go aturan menempel pada tipe data, bukan pada objek request terpisah, sehingga satu struct bisa dipakai ulang di banyak handler.

Pasang paket dan buat satu instance validator untuk dipakai seluruh handler. Versi terkini per Juni 2026 adalah seri v10.30.x.

Terminal
go get github.com/go-playground/validator/v10

Decode JSON dengan aman adalah separuh dari validasi. Pakai json.Decoder dengan DisallowUnknownFields agar field asing ditolak, lalu bedakan tiga jenis kegagalan decode: body kosong, JSON rusak, dan tipe field salah.

internal/shared/httpx/decode.go
package httpx import ( "encoding/json" "errors" "fmt" "io" "net/http" "github.com/go-playground/validator/v10" ) var validate = validator.New(validator.WithRequiredStructEnabled()) // DecodeAndValidate membaca body JSON ke dst lalu memvalidasi struct tag-nya. func DecodeAndValidate(r *http.Request, dst any) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { var syntaxErr *json.SyntaxError var typeErr *json.UnmarshalTypeError switch { case errors.Is(err, io.EOF): return fmt.Errorf("request body is empty") case errors.As(err, &syntaxErr): return fmt.Errorf("request body has malformed JSON at offset %d", syntaxErr.Offset) case errors.As(err, &typeErr): return fmt.Errorf("field %q expects %s", typeErr.Field, typeErr.Type) default: return fmt.Errorf("decode request body: %w", err) } } // Tolak body kedua: client tidak boleh mengirim dua objek JSON. if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return fmt.Errorf("request body must contain a single JSON object") } if err := validate.Struct(dst); err != nil { return err } return nil }

Setelah decoder beres, definisikan struct request berlabuh ke domain skincare. Aturan validasi menempel langsung di tag. Tag oneof berperan seperti enum allowlist, persis seperti in:newest,price_asc di Laravel.

internal/review/request.go
package review // CreateReviewRequest memetakan body POST /v1/reviews. type CreateReviewRequest struct { ProductID int64 `json:"product_id" validate:"required,gt=0"` Rating int `json:"rating" validate:"required,min=1,max=5"` Title string `json:"title" validate:"required,min=3,max=120"` Body string `json:"body" validate:"required,min=10,max=2000"` } // ProductFilterRequest memetakan query string GET /v1/products. type ProductFilterRequest struct { Keyword string `json:"keyword" validate:"omitempty,max=80"` Brand string `json:"brand" validate:"omitempty,alphanum,max=40"` Sort string `json:"sort" validate:"omitempty,oneof=newest price_asc price_desc"` Page int `json:"page" validate:"omitempty,min=1,max=1000"` }

Di handler, urutannya jelas: decode dan validasi, lalu map error ke 422, baru lanjut ke service. Status 422 (Unprocessable Entity) memberi sinyal bahwa JSON sudah benar bentuknya tetapi gagal aturan bisnis, berbeda dari 400 untuk JSON rusak.

internal/review/handler.go
package review import ( "context" "errors" "net/http" "github.com/go-playground/validator/v10" "github.com/kamu/skincare-backend/internal/shared/httpx" ) type Service interface { Create(ctx context.Context, customerID int64, req CreateReviewRequest) (int64, error) } type Handler struct { reviews Service } func (h *Handler) CreateReview(w http.ResponseWriter, r *http.Request) { var req CreateReviewRequest if err := httpx.DecodeAndValidate(r, &req); err != nil { var validationErrs validator.ValidationErrors if errors.As(err, &validationErrs) { httpx.RespondJSON(w, http.StatusUnprocessableEntity, fieldErrors(validationErrs)) return } httpx.RespondError(w, http.StatusBadRequest, err.Error()) return } customerID := httpx.CustomerID(r.Context()) id, err := h.reviews.Create(r.Context(), customerID, req) if err != nil { httpx.RespondError(w, http.StatusInternalServerError, "cannot create review") return } httpx.RespondJSON(w, http.StatusCreated, map[string]int64{"id": id}) } // fieldErrors mengubah error validator menjadi map field -> aturan yang gagal. func fieldErrors(errs validator.ValidationErrors) map[string]any { out := make(map[string]string, len(errs)) for _, e := range errs { out[e.Field()] = e.Tag() } return map[string]any{"errors": out} }
flowchart TD
  Body["Request body JSON"] --> Decode["json.Decoder + DisallowUnknownFields"]
  Decode -->|EOF / syntax / type salah| Bad["400 Bad Request"]
  Decode -->|ok| Bind["Bind ke struct request"]
  Bind --> Validate["validate.Struct(req)"]
  Validate -->|ValidationErrors| Unproc["422 Unprocessable Entity"]
  Validate -->|lolos| Service["Lanjut ke service"]

Gambar 2. Pipeline validasi request: decode aman dulu, baru validasi struct, sebelum service tersentuh.

💡Satu validator, dipakai ulang

Buat satu instance validator.New(…) sebagai package-level var. Instance ini aman dipakai banyak goroutine sekaligus dan men-cache hasil parsing tag, jadi jangan buat ulang per request.

04

SQL Injection dan Parameterized Query

SQL injection terjadi ketika input user ikut menjadi bagian dari teks SQL, bukan dikirim sebagai nilai terpisah.

Di pgx, pola aman adalah menulis SQL dengan placeholder PostgreSQL seperti $1, $2, lalu mengirim nilai user sebagai argumen terpisah. Jangan menggabungkan string dari request ke SQL mentah.

🌉Jembatan: PDO prepared statement & Eloquent ke pgx placeholder

Di PHP, PDO prepared statement (:keyword atau ?) dan Eloquent (where(‘name’, $keyword)) sudah binding nilai otomatis. Konsepnya identik dengan pgx: struktur SQL dipegang server, nilai user dikirim terpisah. Yang berbeda hanya sintaks placeholder, PostgreSQL pakai $1, $2 bernomor, sedangkan MySQL/PDO pakai ? berurutan.

parameterized query

Query yang memisahkan struktur SQL dari nilai input. SQL tetap dikendalikan server, sementara nilai user dikirim sebagai parameter.

internal/product/repository.go
package product import ( "context" "fmt" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Repository struct { db *pgxpool.Pool } type Product struct { ID int64 `json:"id" db:"id"` Name string `json:"name" db:"name"` Slug string `json:"slug" db:"slug"` Brand string `json:"brand" db:"brand"` MinPrice int64 `json:"min_price" db:"min_price"` } type ProductFilter struct { Keyword string BrandID *int64 CategoryID *int64 Limit int32 } func (r *Repository) SearchProducts(ctx context.Context, filter ProductFilter) ([]Product, error) { where := []string{"p.status = $1"} args := []any{"active"} if filter.Keyword != "" { args = append(args, filter.Keyword) where = append(where, fmt.Sprintf("p.name ILIKE '%%' || $%d || '%%'", len(args))) } if filter.BrandID != nil { args = append(args, *filter.BrandID) where = append(where, fmt.Sprintf("p.brand_id = $%d", len(args))) } if filter.CategoryID != nil { args = append(args, *filter.CategoryID) where = append(where, fmt.Sprintf("p.category_id = $%d", len(args))) } limit := filter.Limit if limit <= 0 || limit > 50 { limit = 20 } args = append(args, limit) limitPlaceholder := fmt.Sprintf("$%d", len(args)) sql := ` SELECT p.id, p.name, p.slug, b.name AS brand, MIN(v.price) AS min_price FROM products p JOIN brands b ON b.id = p.brand_id JOIN product_variants v ON v.product_id = p.id WHERE ` + strings.Join(where, " AND ") + ` GROUP BY p.id, p.name, p.slug, b.name ORDER BY p.created_at DESC LIMIT ` + limitPlaceholder rows, err := r.db.Query(ctx, sql, args...) if err != nil { return nil, fmt.Errorf("search products: %w", err) } defer rows.Close() products, err := pgx.CollectRows(rows, pgx.RowToStructByName[Product]) if err != nil { return nil, fmt.Errorf("collect products: %w", err) } return products, nil }

Perhatikan detail pentingnya: strings.Join hanya menyusun fragmen SQL yang dibuat oleh kode server, bukan dari user. Nilai dari user selalu masuk lewat args sehingga PostgreSQL menerima nilai, bukan potongan perintah SQL.

⚠️Jangan concat input user ke SQL

Input seperti ’ OR 1=1 — tidak berbahaya ketika dikirim sebagai parameter, tetapi bisa menghancurkan batas data ketika kamu menyisipkannya langsung ke string SQL.

internal/product/repository_bad.go
package product import ( "context" "fmt" ) func (r *Repository) UnsafeSearchProducts(ctx context.Context, keyword string) error { query := fmt.Sprintf("SELECT id, name FROM products WHERE name ILIKE '%%%s%%'", keyword) _, err := r.db.Query(ctx, query) return err }

Kode buruk di atas terlihat praktis, tetapi ia memberi input user kesempatan mengubah struktur SQL. Dalam proyek produksi, perlakukan semua nilai dari query string, JSON body, header, cookie, dan path param sebagai tidak dipercaya.

Nilai bisa di-parameterize, identifier tidak

Ada batas penting: placeholder $n hanya untuk nilai, bukan untuk nama kolom, nama tabel, atau arah sort. Parameter sort di search produk tidak bisa kamu kirim sebagai $n ke klausa ORDER BY. Solusinya bukan concat string, melainkan allowlist: petakan nilai user ke fragmen SQL tetap yang sudah kamu tulis sendiri.

internal/product/sort.go
package product // sortColumns memetakan nilai sort yang diizinkan ke fragmen ORDER BY tetap. // Nilai user tidak pernah masuk ke SQL; hanya kunci yang dicocokkan. var sortColumns = map[string]string{ "newest": "p.created_at DESC", "price_asc": "min_price ASC", "price_desc": "min_price DESC", } func orderByClause(sort string) string { if clause, ok := sortColumns[sort]; ok { return clause } return "p.created_at DESC" // default aman }
⚠️ORDER BY tidak bisa di-bind

Karena identifier tidak bisa di-parameterize, godaan terbesar adalah fmt.Sprintf(“ORDER BY %s”, sort). Itu jalur SQL injection langsung. Selalu lewat allowlist map, dan validator oneof di Section sebelumnya menjadi lapisan pertama yang menolak nilai sort asing.

05

XSS Awareness untuk API JSON

Go API biasanya mengirim JSON, bukan HTML. Namun text field tetap bisa berubah menjadi XSS ketika dirender di tempat lain.

XSS tidak selalu terjadi di backend. Misalnya customer menulis review berisi tag script, backend menyimpan review sebagai string biasa, lalu admin panel React menampilkannya dengan dangerouslySetInnerHTML. Sumber masalahnya adalah output rendering, tetapi backend tetap perlu sadar bahwa text field bisa membawa payload berbahaya.

🌉Jembatan: escape by default kecuali kamu memaksa raw

React menampilkan {review.body} sebagai teks, raw hanya lewat dangerouslySetInnerHTML. Blade menampilkan {{ $body }} ter-escape, raw hanya lewat {!! $body !!}. Go html/template mengikuti prinsip sama: aman secara default, raw hanya kalau kamu memilih template.HTML secara sadar. Ketiganya: escape by default, raw harus disengaja.

Simpan text field
  • Boleh simpan nama produk, review, dan instruksi pakai sebagai teks biasa.
  • Validasi panjang, required, karakter kontrol, dan business rule.
Render text field
  • Jangan render sebagai HTML mentah.
  • Untuk HTML server-side, gunakan html/template agar escaping otomatis.
internal/review/render_preview.go
package review import ( "fmt" "html/template" "io" ) const reviewPreviewHTML = `<article><h3>Preview review</h3><p>{{.Body}}</p></article>` func RenderReviewPreview(w io.Writer, body string) error { if len(body) > 1000 { return fmt.Errorf("review body too long") } tpl, err := template.New("review-preview").Parse(reviewPreviewHTML) if err != nil { return fmt.Errorf("parse review preview template: %w", err) } data := struct { Body string }{ Body: body, } if err := tpl.Execute(w, data); err != nil { return fmt.Errorf("execute review preview template: %w", err) } return nil }
⚠️text/template TIDAK escape

Jebakan umum: paket html/template melakukan context-aware escaping otomatis, tetapi text/template yang namanya mirip TIDAK escape apa pun. Kalau kamu salah impor dan memakai text/template untuk output HTML, semua proteksi XSS hilang diam-diam. Untuk apa pun yang berakhir di browser, pakai html/template.

📝json.Marshal aman untuk JSON

Untuk respons JSON biasa kamu tidak perlu escaping HTML manual: json.Marshal Go secara default meng-escape <, >, dan & menjadi \u003c, \u003e, \u0026, sehingga string review tetap aman saat di-embed di HTML. Simpan data sebagai teks domain, lalu escape sesuai konteks output: HTML, attribute, URL, JavaScript, atau plain text.

06

Batasi Ukuran Request Body

Validasi tidak berguna kalau server sudah menghabiskan memori sebelum sempat memvalidasi.

Gunakan http.MaxBytesReader untuk membatasi ukuran request body. Ini penting untuk endpoint JSON, upload gambar, dan webhook. Tanpa limit, client bisa mengirim body sangat besar dan membuat server boros memori atau disk.

🌉Jembatan: body-parser limit & php.ini ke MaxBytesReader

Di Express kamu set express.json({ limit: ‘1mb’ }), di PHP kamu atur upload_max_filesize dan post_max_size di php.ini secara global. Go tidak punya knob global; http.MaxBytesReader dipasang eksplisit per handler atau per group route. Lebih verbose, tetapi tiap endpoint bisa punya limit sendiri (JSON kecil, upload besar) tanpa saling mengganggu.

internal/shared/middleware/body_limit.go
package middleware import "net/http" func BodyLimit(maxBytes int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) next.ServeHTTP(w, r) }) } }

Middleware di atas hanya membungkus body. Limit baru terpicu nanti, saat handler benar-benar membaca body melewati batas. Sejak Go 1.19, MaxBytesReader mengembalikan error bertipe *http.MaxBytesError ketika limit terlampaui. Bedakan ia dari error decode lain dengan errors.As agar status code-nya tepat: 413 untuk terlalu besar, 400 untuk JSON rusak.

internal/shared/httpx/decode.go
func mapBodyError(w http.ResponseWriter, err error) { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { RespondError(w, http.StatusRequestEntityTooLarge, "request body is too large") return } RespondError(w, http.StatusBadRequest, err.Error()) }
POST /v1/admin/products/{id}/images Upload gambar produk dengan body limit, MIME validation, dan nama file buatan server
POST /v1/webhooks/payment Webhook tetap perlu body limit sebelum signature verification membaca payload
POST /v1/reviews Review text perlu limit ukuran JSON dan limit panjang field
💡Prinsip ukuran

Limit body endpoint upload biasanya berbeda dari JSON biasa. JSON bisa 1 MB atau lebih kecil, gambar produk bisa 5 MB per file, dan nilai final harus disesuaikan kebutuhan bisnis.

07

Upload Gambar Produk yang Aman

Upload file adalah input paling berisiko karena ia menyentuh parser multipart, memori, storage, CDN, dan kadang diproses ulang oleh image pipeline.

Jangan percaya extension file dari user. File bernama serum.jpg belum tentu benar-benar JPEG. Gunakan http.DetectContentType pada awal isi file, batasi ukuran file, dan generate object key sendiri.

🌉Jembatan: finfo_file & Laravel mimes ke DetectContentType

Di PHP, finfo_file dan rule Laravel image|mimes:jpeg,png mengecek isi file (magic bytes), bukan sekadar extension. http.DetectContentType melakukan hal yang sama: membaca byte awal dan menebak Content-Type. Pola amannya identik, sniff isi lalu cocokkan dengan allowlist tipe yang kamu izinkan.

MIME sniffing

Proses membaca byte awal file untuk menebak Content-Type. Di Go, http.DetectContentType membaca maksimal 512 byte awal sesuai algoritma mimesniff WHATWG.

internal/product/handler_upload.go
package product import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "github.com/go-chi/chi/v5" ) const maxProductImageBytes int64 = 5 << 20 const maxUploadBodyBytes int64 = 6 << 20 var allowedProductImageTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } type Handler struct { products ProductService images ImageStore } type ProductService interface { AttachImage(ctx context.Context, productID int64, imageURL string) error } type ImageStore interface { Save(ctx context.Context, key string, contentType string, body io.Reader) (string, error) } type uploadProductImageResponse struct { URL string `json:"url"` } func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUploadBodyBytes) defer r.Body.Close() productID, err := parseProductID(r) if err != nil { respondError(w, http.StatusBadRequest, "invalid product id") return } if err := r.ParseMultipartForm(maxUploadBodyBytes); err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { respondError(w, http.StatusRequestEntityTooLarge, "upload body is too large") return } respondError(w, http.StatusBadRequest, "malformed multipart form") return } file, _, err := r.FormFile("image") if err != nil { respondError(w, http.StatusBadRequest, "image file is required") return } defer file.Close() head := make([]byte, 512) n, err := io.ReadFull(file, head) if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { respondError(w, http.StatusBadRequest, "cannot read image") return } contentType := http.DetectContentType(head[:n]) ext, ok := allowedProductImageTypes[contentType] if !ok { respondError(w, http.StatusUnsupportedMediaType, "only jpeg, png, and webp images are allowed") return } reader := io.MultiReader(bytes.NewReader(head[:n]), file) var buf bytes.Buffer size, err := io.Copy(&buf, io.LimitReader(reader, maxProductImageBytes+1)) if err != nil { respondError(w, http.StatusBadRequest, "cannot read image") return } if size > maxProductImageBytes { respondError(w, http.StatusRequestEntityTooLarge, "image is too large") return } key, err := productImageKey(productID, ext) if err != nil { respondError(w, http.StatusInternalServerError, "cannot create image key") return } url, err := h.images.Save(r.Context(), key, contentType, bytes.NewReader(buf.Bytes())) if err != nil { respondError(w, http.StatusInternalServerError, "cannot store image") return } if err := h.products.AttachImage(r.Context(), productID, url); err != nil { respondError(w, http.StatusInternalServerError, "cannot attach image") return } respondJSON(w, http.StatusCreated, uploadProductImageResponse{URL: url}) } func parseProductID(r *http.Request) (int64, error) { productID := chi.URLParam(r, "id") return strconv.ParseInt(productID, 10, 64) } func productImageKey(productID int64, ext string) (string, error) { name, err := randomHex(16) if err != nil { return "", err } return fmt.Sprintf("products/%d/%s%s", productID, name, ext), nil } func randomHex(size int) (string, error) { b := make([]byte, size) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("read random bytes: %w", err) } return hex.EncodeToString(b), nil } func respondJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) }
Limit body lebih awal

http.MaxBytesReader dipasang sebelum parser multipart membaca body.

Ambil 512 byte awal

http.DetectContentType menentukan MIME type dari isi file, bukan dari nama file.

Allowlist MIME type

Hanya image/jpeg, image/png, dan image/webp yang diterima untuk gambar produk.

Generate nama file

Nama object dibuat server dengan random bytes, sehingga nama file user tidak pernah menjadi path storage.

⚠️Jangan hanya cek extension

Extension bisa dipalsukan. File shell.php.jpg tetap bisa lolos jika kamu hanya mengecek akhiran nama file.

flowchart TD
  Start["POST upload gambar"] --> Limit{"Body lewat MaxBytesReader?"}
  Limit -->|"*http.MaxBytesError"| E413a["413 Request Entity Too Large"]
  Limit -->|"error multipart lain"| E400["400 Bad Request"]
  Limit -->|ok| Mime{"MIME di allowlist?"}
  Mime -->|tidak| E415["415 Unsupported Media Type"]
  Mime -->|ya| Size{"Ukuran <= limit per file?"}
  Size -->|tidak| E413b["413 Request Entity Too Large"]
  Size -->|ya| OK["201 Created"]

Gambar 3. Alur keputusan status code saat upload: tiap kegagalan punya kode yang tepat.

📝DetectContentType hanya menebak

http.DetectContentType hanya membaca 512 byte awal dan menebak tipe; ia TIDAK menjamin file adalah gambar valid yang bisa di-decode. Untuk validasi lebih kuat, decode header gambar dengan image.DecodeConfig (setelah image/jpeg, image/png di-import). Jika decode gagal, file bukan gambar valid meski byte awalnya menyerupai satu.

📝Buffer penuh vs streaming

Contoh di atas membaca seluruh file ke bytes.Buffer agar mudah dibaca. Untuk file besar di produksi, ini sebagian menggugurkan tujuan MaxBytesReader karena memori tetap terpakai penuh. Trade-off-nya: streaming langsung ke object storage (mis. S3 multipart upload) lebih hemat memori, tetapi kamu kehilangan kemudahan memeriksa ukuran final sebelum menulis. Pilih sesuai ukuran file dan beban server.

08

Path Traversal dan Nama File

Path traversal terjadi ketika input user dipakai sebagai path file dan mencoba keluar dari folder yang kamu izinkan.

Contoh payload klasik adalah ../../../../etc/passwd. Untuk object storage seperti S3, dampaknya bukan membaca file OS, tetapi bisa tetap merusak struktur object key, menimpa object lain, atau menghasilkan URL yang tidak sesuai aturan.

path traversal

Serangan yang memakai segmen path seperti .. untuk keluar dari direktori yang diharapkan.

internal/shared/storage/local.go
package storage import ( "fmt" "os" "path/filepath" "strings" ) func JoinUnderRoot(root string, generatedName string) (string, error) { fullPath := filepath.Join(root, generatedName) rel, err := filepath.Rel(root, fullPath) if err != nil { return "", fmt.Errorf("calculate relative path: %w", err) } if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { return "", fmt.Errorf("path escapes storage root") } return fullPath, nil }

Fungsi di atas berguna untuk local storage ketika kamu sudah punya nama file buatan server. Namun guardrail paling kuat tetap sederhana: jangan pakai nama file dari user sebagai nama file final.

📝S3 dan object storage: filepath.Join tidak relevan

Contoh di atas fokus local storage, di mana filepath.Join dan filepath.Rel bekerja dengan pemisah path OS. Untuk S3 atau object storage, object key memakai / sebagai pemisah logis, bukan path OS, jadi filepath.Join tidak tepat (di Windows ia bahkan menghasilkan \). Sanitasi object key dengan cara berbeda: tolak segmen .. dan //, batasi ke karakter yang kamu izinkan, dan selalu buat key dari nilai server (productID + random hex), bukan dari nama file user.

Area input security di proyek skincare
  • internal/
  • shared/
  • httpx/
  • decode.go decode JSON aman + validasi struct
  • middleware/
  • body_limit.go limit request body
  • storage/
  • local.go cegah path keluar dari root
  • product/
  • handler_upload.go upload gambar produk aman
  • repository.go query pgx parameterized
  • sort.go allowlist ORDER BY
  • review/
  • request.go struct request + tag validator
  • handler.go map error validasi ke 422
  • render_preview.go contoh escaping saat render HTML
09

Integrasi Route, Handler, dan Service

Input security bukan hanya kode validasi. Ia harus terpasang di route, handler, service, dan repository.

Endpoint upload gambar produk seharusnya berada di route admin, bukan customer. Auth dan otorisasi dibahas di modul sebelumnya, tetapi input security tetap berlaku setelah user terbukti sebagai admin.

cmd/api/routes.go
package main import ( "net/http" "github.com/go-chi/chi/v5" sharedmw "github.com/kamu/skincare-backend/internal/shared/middleware" ) type ProductImageHandler interface { UploadProductImage(w http.ResponseWriter, r *http.Request) } func productAdminRoutes(r chi.Router, handler ProductImageHandler) { r.Group(func(r chi.Router) { r.Use(AdminOnly) r.Use(sharedmw.BodyLimit(6 << 20)) r.Post("/v1/admin/products/{id}/images", handler.UploadProductImage) }) }
sequenceDiagram
  participant Admin as Admin Panel
  participant Router as chi Router
  participant Handler as Upload Handler
  participant Store as Image Store
  participant Product as Product Service
  participant DB as PostgreSQL
  Admin->>Router: POST /v1/admin/products/42/images multipart
  Router->>Router: AdminOnly + BodyLimit
  Router->>Handler: UploadProductImage
  Handler->>Handler: DetectContentType + size check
  Handler->>Store: Save generated object key
  Handler->>Product: Attach image URL
  Product->>DB: UPDATE products SET image_url = $1 WHERE id = $2
  DB-->>Product: ok
  Product-->>Handler: ok
  Handler-->>Admin: 201 Created

Gambar 4. Route admin tetap melewati body limit dan validasi file sebelum menyentuh storage atau database.

💡Layering praktis

Handler menjaga format dan keamanan input HTTP. Service menjaga business rule. Repository menjaga query aman dengan parameterized query.

10

Hands-on Ringan

Latihan ini mengecek tiga hal: SQL injection tidak mengubah query, upload palsu ditolak, dan file besar ditolak.

Uji keyword berbahaya

Kirim keyword yang terlihat seperti SQL injection dan pastikan response tetap hanya hasil search normal.

Uji MIME palsu

Buat file teks dengan extension .jpg, lalu upload sebagai gambar produk. Endpoint harus menolak dengan 415.

Uji ukuran file

Buat file lebih besar dari limit upload. Endpoint harus menolak dengan 413.

Terminal
printf 'not really an image' > fake.jpg truncate -s 7M too-big.jpg curl -i 'http://localhost:8080/v1/products?q=%27%20OR%201%3D1%20--' curl -i -X POST 'http://localhost:8080/v1/admin/products/42/images' \ -H 'Authorization: Bearer <admin-token>' \ -F 'image=@fake.jpg' curl -i -X POST 'http://localhost:8080/v1/admin/products/42/images' \ -H 'Authorization: Bearer <admin-token>' \ -F 'image=@too-big.jpg'
🧪Untuk test otomatis

Di Roadmap 6, pola ini bisa diubah menjadi integration test dengan httptest.NewServer, file fixture, dan database test.

11

Jebakan Umum dari JS dan PHP

Pendatang dari JS dan PHP biasanya bukan gagal karena tidak tahu security, tetapi karena mengandalkan kebiasaan framework di tempat yang Go buat eksplisit.

Menyamakan validation dengan sanitization

Validasi menjawab apakah input boleh diterima. Escaping menjawab bagaimana output dirender. Keduanya bukan hal yang sama.

String concat untuk SQL dinamis

Fragmen SQL boleh disusun oleh kode server, tetapi nilai user tetap harus lewat placeholder dan args pgx.

Percaya extension file

Extension adalah metadata dari user. Isi file harus dicek dengan MIME sniffing dan allowlist.

Pakai nama file user

Nama file user bisa mengandung karakter aneh, duplikat, atau path traversal. Generate nama file di server.

Body limit dipasang terlambat

Limit harus dipasang sebelum JSON decoder atau multipart parser membaca body.

Escape saat masuk database

Untuk text field biasa, simpan nilai domain. Escape saat output sesuai konteks render.

Lupa DisallowUnknownFields

Tanpa itu, field asing di body diam-diam diabaikan. Tolak input yang tidak dikenal sejak decode agar bug dan typo client cepat ketahuan.

Parameterize identifier SQL

Nilai bisa di-bind dengan $n, tetapi nama kolom dan arah ORDER BY tidak. Pakai allowlist, bukan concat string.

⚠️Jangan pindahkan semua risiko ke frontend

Frontend membantu UX, tetapi attacker bisa memanggil API langsung. Semua validasi keamanan tetap wajib ada di backend.

12

Ringkasan & Poin Penting

Keamanan input adalah lapisan yang membuat auth, repository, storage, dan admin route tidak mudah disalahgunakan.

Yang Wajib Menempel

  • Request validation lewat validator/v10: decode JSON aman dengan DisallowUnknownFields, validasi struct tag, lalu map ValidationErrors ke 422.
  • SQL injection dicegah dengan parameterized query pgx, placeholder $1, $2, dan args terpisah; identifier seperti ORDER BY butuh allowlist, bukan $n.
  • XSS tidak hilang hanya karena backend mengirim JSON. Text field tetap harus dirender aman di React, email HTML, atau admin preview.
  • Upload gambar produk harus membatasi body, mengecek MIME type dengan http.DetectContentType, allowlist tipe, dan generate nama file server-side.
  • http.MaxBytesReader dipakai sebelum body dibaca agar request besar tidak memboroskan resource.
  • Path traversal dicegah dengan tidak memakai input user sebagai nama file, serta memastikan path lokal tetap berada di storage root.
  • Di proyek skincare, guardrail ini melindungi search produk, review, webhook, dan upload gambar admin.

Rujukan resmi untuk dibaca ulang

Pada modul berikutnya di Roadmap 7, input security ini menjadi dasar untuk topik production safety lain: webhook verification, secret management, audit log, dan hardening konfigurasi sebelum deploy ke AWS.

Progress disimpan lokal di browser ini.