Redirect adalah respons yang paling singkat sekaligus paling sering salah: body-nya kosong, seluruh pesannya terangkum dalam satu header Location dan satu angka status, tetapi kesalahan kecil langsung terasa — pengguna terjebak berputar, form terkirim dua kali, atau tautan rusak setelah migrasi. Chapter response status codes sudah memetakan kelima kode redirect dari sisi pemilihan angkanya; chapter ini membedah sisi mekanismenya: cara membangun respons redirect yang teruji, aturan nilai Location absolut versus relatif, pola POST-Redirect-GET yang mengandalkan flash data, deteksi loop redirect, dan validasi target eksternal agar fitur yang nyaman ini tidak berubah menjadi pintu masuk penipuan.

Membangun Respons Redirect #

Respons redirect yang benar terdiri dari tiga bagian: kode status dari keluarga 3xx, header Location, dan exit setelahnya agar eksekusi berhenti. Kelima kode tersebut layak dirangkum ulang dari sudut pandang pembangun respons:

KodeMetode request berikutnyaPermanenPemakaian khas
301umumnya berubah jadi GETyapindah URL/domain selamanya
302umumnya berubah jadi GETtidakpengalihan sementara umum
303selalu GETtidaksetelah POST sukses (PRG)
307dipertahankan persistidaksementara, form tetap POST
308dipertahankan persisyapermanen, metode tak berubah

Pembangunnya bisa dibakukan menjadi fungsi murni yang mengembalikan triple respons seperti pola chapter-chapter sebelumnya:

php
<?php
function responsRedirect(string $target, int $kode = 302): array
{
    if (!in_array($kode, [301, 302, 303, 307, 308], true)) {
        throw new InvalidArgumentException("Kode redirect tidak sah: $kode");
    }

    return [$kode, ["Location: $target"], ""];
}

foreach ([
    ["/login", 302],
    ["/pesanan/terakhir", 303],
    ["https://www.example.com/toko-baru", 301],
] as [$target, $kode]) {
    [$status, $headers] = responsRedirect($target, $kode);
    printf("%d %s\n", $status, $headers[0]);
}

Output yang Dihasilkan:

text
302 Location: /login
303 Location: /pesanan/terakhir
301 Location: https://www.example.com/toko-baru

Validasi in_array() yang strict memastikan hanya lima kode sah yang lolos — redirect dengan kode kebetulan seperti 200 atau 305 adalah bug yang sulit dilacak karena klien mengabaikannya secara diam-diam. Di skrip web nyata, triple ini dikirim dengan header("Location: $target", true, $kode) lalu exit;, persis pola yang diperlihatkan chapter mengirim HTTP headers .

Location Absolut dan Relatif #

Spesifikasi HTTP lama mewajibkan Location berisi URL absolut; RFC 7231 melonggarkannya sehingga nilai relatif kini sah dan lazim. Bagaimana klien menerjemahkan nilai relatif mengikuti aturan resolusi URL — aturan yang bisa kita tiru dalam fungsi murni:

php
<?php
function locationAbsolut(string $lokasi, string $urlBasis): string
{
    $bagian = parse_url($urlBasis);
    $akar = $bagian["scheme"] . "://" . $bagian["host"];

    if (str_starts_with($lokasi, "/")) {
        return $akar . $lokasi;
    }

    $path = $bagian["path"] ?? "/";
    $direktori = substr($path, 0, strrpos($path, "/") + 1);
    return $akar . $direktori . $lokasi;
}

echo locationAbsolut("/login", "https://toko.example.com/keranjang/checkout") . "\n";
echo locationAbsolut("detail/7", "https://toko.example.com/produk/daftar") . "\n";

Output yang Dihasilkan:

text
https://toko.example.com/login
https://toko.example.com/produk/detail/7

Dua bentuk relatif punya makna berbeda: nilai berawalan / (path absolut) dihitung dari akar situs, sehingga /login menjadi https://toko.example.com/login terlepas dari halaman mana redirect itu dipancarkan. Nilai tanpa awalan (path relatif) dihitung terhadap direktori URL saat ini — detail/7 yang dipancarkan dari /produk/daftar menempel pada /produk/, menghasilkan /produk/detail/7. Dalam praktik, gunakan path absolut berawalan / untuk hasil yang mudah diprediksi, dan sisakan bentuk relatif murni untuk kasus seperti “kembali ke halaman sebelumnya”.

Pola POST-Redirect-GET dengan Flash Data #

Masalah klasik form: setelah POST sukses, halaman hasil bisa dikirim ulang begitu pengguna menekan refresh — dan pesanan tercatat dua kali. Pola POST-Redirect-GET (PRG) memutus lingkaran itu: respons atas POST bukan halaman hasil, melainkan redirect 303, sehingga refresh hanya mengulang GET halaman hasil yang aman. Satu pertanyaan tersisa: bagaimana halaman hasil tahu bahwa penyimpanan berhasil? Jawabannya flash data — pesan sekali-baca yang disimpan di session:

php
<?php
function simpanFlash(array $sesi, string $kunci, string $pesan): array
{
    $sesi["_flash"][$kunci] = $pesan;
    return $sesi;
}

function ambilFlash(array &$sesi, string $kunci): ?string
{
    if (!isset($sesi["_flash"][$kunci])) {
        return null;
    }
    $pesan = $sesi["_flash"][$kunci];
    unset($sesi["_flash"][$kunci]);
    return $pesan;
}

// Simulasi tiga permintaan berturut-turut dalam satu sesi
$sesi = [];

// POST /simpan-pesanan — sukses: simpan flash, arahkan 303
$sesi = simpanFlash($sesi, "sukses", "Pesanan Budi berhasil disimpan");

// GET /pesanan/terakhir — halaman hasil membaca pesan sekali
echo "GET hasil  : " . (ambilFlash($sesi, "sukses") ?? "(kosong)") . "\n";

// GET /pesanan/terakhir — pengguna menekan refresh
echo "GET ulang  : " . (ambilFlash($sesi, "sukses") ?? "(kosong)") . "\n";

Output yang Dihasilkan:

text
GET hasil  : Pesanan Budi berhasil disimpan
GET ulang  : (kosong)

Simulasi di atas menampilkan sifat kunci flash data: pesan tersimpan saat POST diproses, terbaca penuh pada GET pertama, lalu menghilang pada GET kedua karena ambilFlash() menghapus kunci segera setelah dibaca. Kode 303 dipilih bukan 302 karena spesifikasinya menegaskan request berikutnya harus GET — perilaku yang dijamin, bukan kebiasaan historis browser. Pada aplikasi nyata, array $sesi digantikan $_SESSION dan fungsi seperti ini tinggal dibungkus; pola lengkapnya beserta pengaturan session dibahas di chapter flash messages dan sessions .

Mendeteksi Loop Redirect #

Loop redirect terjadi ketika A mengarah ke B dan B mengarah kembali ke A — browser menyerah setelah sekitar dua puluh langkah dengan galat “too many redirects”. Penyebab umumnya logika otentikasi yang saling menendang: halaman /dashboard mengarah ke /login karena belum login, tetapi /login justru mengarah ke /dashboard karena dianggap sudah login. Deteksinya sederhana: catat setiap URL yang dikunjungi, dan berhenti begitu satu URL terulang:

php
<?php
function deteksiLoop(array $jejak): ?string
{
    $dilihat = [];
    foreach ($jejak as $langkah => $url) {
        if (isset($dilihat[$url])) {
            return "loop: $url muncul lagi di langkah $langkah (sebelumnya langkah {$dilihat[$url]})";
        }
        $dilihat[$url] = $langkah;
    }
    return null;
}

$sehat = ["/login", "/dashboard", "/laporan"];
$berputar = ["/awal", "/tengah", "/awal"];

var_dump(deteksiLoop($sehat));
echo deteksiLoop($berputar) . "\n";

Output yang Dihasilkan:

text
NULL
loop: /awal muncul lagi di langkah 2 (sebelumnya langkah 0)

Rantai sehat menghasilkan NULL — tidak ada URL yang terulang — sedangkan rantai berputar tertangkap pada langkah ketiga ketika /awal muncul untuk kedua kalinya. Dalam pengujian, fungsi ini bisa diberi umpan rantai redirect dari konfigurasi routing untuk memastikan tidak ada pasangan aturan yang saling mengarah; di produksi, kapasitas pencatat berbasis Redis memberi versi lintas-proses dari alur ini.

Redirect Eksternal dan Bahaya Open Redirect #

Momentum redirect sering dimanfaatkan untuk fitur seperti ?redirect=/checkout setelah login. Di sinilah letak celah open redirect: penyerang mengirim tautan https://toko.example.com/login?redirect=https://penipu.example.org/phishing — pengguna melihat domain Anda yang tepercaya, lalu diteruskan diam-diam ke domain tiruan. Validasi target sebelum memancarkan redirect bukan opsional:

php
<?php
function targetRedirectAman(?string $target, string $hostAplikasi): bool
{
    if ($target === null || $target === "") {
        return false;
    }

    // target relatif: boleh, asal bukan "//host" (URL relatif-skema)
    if ($target[0] === "/") {
        return !str_starts_with($target, "//") && !str_contains($target, "\\");
    }

    // target absolut: host-nya harus milik aplikasi
    $host = parse_url($target, PHP_URL_HOST);
    return $host !== null && strcasecmp($host, $hostAplikasi) === 0;
}

foreach ([
    "/dashboard",
    "//penipu.example.org/jebakan",
    "https://toko.example.com/logout",
    "https://penipu.example.org/phishing",
    "javascript:alert(1)",
] as $target) {
    printf("%-36s => %s\n", $target, targetRedirectAman($target, "toko.example.com") ? "aman" : "DITOLAK");
}

Output yang Dihasilkan:

text
/dashboard                           => aman
//penipu.example.org/jebakan         => DITOLAK
https://toko.example.com/logout      => aman
https://penipu.example.org/phishing  => DITOLAK
javascript:alert(1)                  => DITOLAK

Mari kita bedah aturan-aturannya:

  1. Target berawalan / diterima karena selalu berada di dalam domain pengirim — kecuali bentuk //penipu.example.org, yaitu URL relatif-skema yang melengkapi skema dari halaman saat ini sehingga lompat ke host lain.
  2. Karakter \ ditolak sebagai antisipasi klien yang menormalkan backslash menjadi / — tanpa pemeriksaan ini, /\/penipu.example.org bisa lolos sebagai “path relatif”.
  3. Target absolut diurai dengan parse_url() dan host-nya dibandingkan case-insensitive dengan domain aplikasi; https://penipu.example.org/phishing dan javascript:alert(1) (yang tidak memiliki host) sama-sama gugur.

Untuk kebutuhan yang lebih longgar — misalnya boleh ke beberapa domain afiliasi — ganti pembandingan tunggal dengan daftar izin (allowlist) domain, tetapi prinsipnya tetap: daftar eksplisit, bukan daftar hitam.

Menguji di Server Web Sungguhan #

Router kecil berikut memperlihatkan redirect dari awal sampai tindak lanjutnya:

php
<?php
// router-redirect.php — dari folder berkas ini, jalankan:
//   php -S 127.0.0.1:8088 router-redirect.php
// lalu amati responsnya:
//   curl -i http://127.0.0.1:8088/lama
//   curl -sL -o /dev/null -w '%{url_effective} %{http_code}\n' http://127.0.0.1:8088/lama
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);

if ($path === "/lama") {
    header("Location: /baru", true, 302);
    exit;
}

http_response_code(200);
echo "Ini halaman /baru\n";

Permintaan ke /lama dijawab HTTP/1.1 302 Found dengan satu header Location: /baru — nilai relatif, persis yang dibahas di atas. Opsi -L membuat curl mengeksekusi tindak lanjutnya: nilai relatif diselesaikan menjadi http://127.0.0.1:8088/baru, permintaan kedua dijawab 200, dan ringkasan -w mencetak http://127.0.0.1:8088/baru 200. Itulah keseluruhan hidup sebuah redirect dari sisi server: satu header, satu status, sisanya adalah keputusan klien. Matikan server setelah selesai dengan Ctrl+C atau pkill -f "php -S".

Ringkasan #

Respons redirect dibangun dari pasangan kode 3xx dan header Location lalu ditutup exit; Location relatif sah dengan aturan resolusi path absolut versus relatif, 303 adalah kode yang didefinisikan tegas untuk usai POST, dan flash data menyeberangkan pesan sukses melintasi redirect dengan jaminan sekali-baca. Loop redirect terdeteksi dengan pencatat URL terkunjungi, sedangkan target redirect dari input pengguna wajib divalidasi — tolak //host, backslash, dan domain asing — agar fitur nyaman ini tidak menjadi pintu phishing. Respons kita selama ini selalu berupa teks polos; chapter terakhir section ini memampatnya: response compression lewat gzip.

Referensi #

Berkas Sumber

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

contoh.php

Unduh berkas
php
<?php
// contoh.php — ringkasan respons redirect: pembangun respons,
// resolusi Location relatif, flash data PRG, deteksi loop,
// dan validasi target redirect.

function responsRedirect(string $target, int $kode = 302): array
{
    if (!in_array($kode, [301, 302, 303, 307, 308], true)) {
        throw new InvalidArgumentException("Kode redirect tidak sah: $kode");
    }

    return [$kode, ["Location: $target"], ""];
}

function locationAbsolut(string $lokasi, string $urlBasis): string
{
    $bagian = parse_url($urlBasis);
    $akar = $bagian["scheme"] . "://" . $bagian["host"];

    if (str_starts_with($lokasi, "/")) {
        return $akar . $lokasi;
    }

    $path = $bagian["path"] ?? "/";
    $direktori = substr($path, 0, strrpos($path, "/") + 1);
    return $akar . $direktori . $lokasi;
}

function simpanFlash(array $sesi, string $kunci, string $pesan): array
{
    $sesi["_flash"][$kunci] = $pesan;
    return $sesi;
}

function ambilFlash(array &$sesi, string $kunci): ?string
{
    if (!isset($sesi["_flash"][$kunci])) {
        return null;
    }
    $pesan = $sesi["_flash"][$kunci];
    unset($sesi["_flash"][$kunci]);
    return $pesan;
}

function deteksiLoop(array $jejak): ?string
{
    $dilihat = [];
    foreach ($jejak as $langkah => $url) {
        if (isset($dilihat[$url])) {
            return "loop: $url muncul lagi di langkah $langkah (sebelumnya langkah {$dilihat[$url]})";
        }
        $dilihat[$url] = $langkah;
    }
    return null;
}

function targetRedirectAman(?string $target, string $hostAplikasi): bool
{
    if ($target === null || $target === "") {
        return false;
    }

    if ($target[0] === "/") {
        return !str_starts_with($target, "//") && !str_contains($target, "\\");
    }

    $host = parse_url($target, PHP_URL_HOST);
    return $host !== null && strcasecmp($host, $hostAplikasi) === 0;
}

echo "=== Tiga respons redirect ===\n";
foreach ([
    ["/login", 302],
    ["/pesanan/terakhir", 303],
    ["https://www.example.com/toko-baru", 301],
] as [$target, $kode]) {
    [$status, $headers] = responsRedirect($target, $kode);
    printf("%d %s\n", $status, $headers[0]);
}

echo "=== Resolusi Location relatif ===\n";
echo locationAbsolut("/login", "https://toko.example.com/keranjang/checkout") . "\n";
echo locationAbsolut("detail/7", "https://toko.example.com/produk/daftar") . "\n";

echo "=== Flash data PRG ===\n";
$sesi = [];
$sesi = simpanFlash($sesi, "sukses", "Pesanan Budi berhasil disimpan");
echo "GET hasil  : " . (ambilFlash($sesi, "sukses") ?? "(kosong)") . "\n";
echo "GET ulang  : " . (ambilFlash($sesi, "sukses") ?? "(kosong)") . "\n";

echo "=== Deteksi loop ===\n";
var_dump(deteksiLoop(["/login", "/dashboard", "/laporan"]));
echo deteksiLoop(["/awal", "/tengah", "/awal"]) . "\n";

echo "=== Validasi target redirect ===\n";
foreach ([
    "/dashboard",
    "//penipu.example.org/jebakan",
    "https://toko.example.com/logout",
    "https://penipu.example.org/phishing",
    "javascript:alert(1)",
] as $target) {
    printf("%-36s => %s\n", $target, targetRedirectAman($target, "toko.example.com") ? "aman" : "DITOLAK");
}