Token Rahasia di Aplikasi Flutter: Kenapa String.fromEnvironment dan --dart-define Tidak Menyembunyikannya

Artikel
Authors
Token Rahasia di Aplikasi Flutter: Kenapa String.fromEnvironment dan --dart-define Tidak Menyembunyikannya

Kamu menulis satu baris konfigurasi supaya aplikasi bisa mengirim log error ke Telegram. Token bot ditaruh sebagai nilai default, supaya developer lain tidak perlu repot mengisi apa pun saat build. Aplikasinya rilis, dipasang di HP pengguna, semuanya jalan.

Yang tidak terlihat: token itu ikut terkirim ke setiap HP yang memasang aplikasinya. Siapa pun yang membongkar file APK bisa membacanya, lalu mengirim pesan ke channel itu sesukanya.

Ini saya temukan di aplikasi HP untuk salah satu proyek klien yang saya pegang. Token bot Telegram untuk log error tertanam sebagai nilai default String.fromEnvironment. Lebih buruk lagi, bot itu bot pribadi, bukan akun proyek.

Artikel ini membahas kenapa nilai seperti itu selalu ikut ke dalam hasil build, kenapa menghapusnya dari kode tidak menarik kembali yang sudah beredar, kenapa --dart-define juga bukan tempat menyimpan rahasia, dan langkah yang benar-benar menutup aksesnya. Semua screenshot berasal dari demo Dart yang saya jalankan dengan token palsu.

Kenapa token di kode Flutter selalu ikut ke HP pengguna

String.fromEnvironment adalah konstanta yang nilainya ditentukan saat compile. Kalau tidak ada nilai yang diberikan saat build, Dart memakai defaultValue. Dalam kedua kasus, hasilnya adalah string biasa yang ditulis langsung ke dalam hasil build.

Di build release Flutter untuk Android, kode Dart dikompilasi ahead-of-time menjadi libapp.so di dalam APK. APK sendiri hanya file zip. Membukanya tidak butuh alat khusus: unzip untuk mengeluarkan libapp.so, lalu strings untuk menampilkan semua teks yang bisa dibaca di dalamnya. Teks yang dipakai aplikasi, termasuk URL dan token, muncul utuh.

Karena itu ada dua salinan yang tidak bisa ditarik kembali:

  • Setiap APK yang sudah dibagikan. File di HP pengguna, di folder unduhan, atau di grup chat tidak berubah ketika kode sumbernya diubah.
  • Riwayat git. Commit yang menghapus token hanya menambah versi baru. Versi lama tetap ada dan bisa dibaca siapa pun yang punya akses ke repo.

Ada satu jebakan lagi. Pindah dari defaultValue ke --dart-define terlihat seperti solusi, karena token tidak lagi tertulis di kode. Tapi --dart-define hanya mengganti dari mana nilai itu datang saat build. Nilainya tetap ditulis ke dalam libapp.so dengan cara yang sama.

Isi commit perbaikannya

Perbaikannya ada di satu commit, 26 Agustus 2026:

  • Default dihapus. Token dan chat ID tidak lagi punya nilai default di kode. Kalau perlu log jarak jauh, nilainya diberikan lewat --dart-define saat build.
  • Build tanpa token tetap jalan. Logger memang sudah memeriksa token dan chat ID sebelum mengirim apa pun, dan berhenti diam-diam kalau salah satunya kosong. Jadi build tanpa --dart-define hanya tidak mengirim log.
  • Satu-satunya penutup ditulis terang di pesan commit. Menghapus token dari kode tidak menarik kembali yang sudah beredar. Token itu ada di riwayat git dan di setiap APK yang sudah dibagikan. Yang benar-benar menutup akses hanya mencabut token itu lewat BotFather dan membuat yang baru.

Di commit yang sama ada perbaikan kecil yang berhubungan: fungsi logout dulu menelan error-nya. Di build release tidak ada yang terjadi dan tidak ada yang tercatat, padahal logout yang gagal berarti token login masih ada di HP sementara pengguna mengira sudah keluar. Sekarang kegagalan itu dicatat, dan pengguna diberi tahu bahwa sesinya masih aktif.

Untuk membuktikan setiap klaim di atas, saya membuat program Dart kecil yang memakai token persis seperti logger aslinya: token menjadi bagian URL API Telegram. Lalu saya compile tiga versi dengan dart compile aot-snapshot, compiler AOT yang sama yang menghasilkan libapp.so di Flutter:

Isi tiga hasil build dicari dengan strings
Isi tiga hasil build dicari dengan strings

Versi dengan default menyimpan token utuh di dalam URL. Versi tanpa default dan tanpa --dart-define bersih. Versi tanpa default tapi di-build dengan --dart-define kembali menyimpan token. Ketiganya berjalan sesuai harapan:

Tiga build dijalankan
Tiga build dijalankan

Jadi perbaikan di commit itu menutup satu hal: token tidak lagi ikut di setiap build secara otomatis. Build yang memakai --dart-define tetap membawa tokennya, dan itu harus diperlakukan sebagai token yang bisa dibaca orang.

Menutup kebocoran token, urut dari yang paling penting

Langkah 1: cabut tokennya dulu

Urutannya penting. Sebelum mengubah kode, cabut token yang bocor dan buat yang baru. Untuk bot Telegram, ini dilakukan lewat BotFather. Untuk API key lain, lewat dashboard penyedianya. Selama token lama masih berlaku, semua langkah berikutnya hanya kosmetik.

Langkah 2: cari rahasia yang tertanam di kode

grep -rn "defaultValue:" lib/ | grep -i -E "token|key|secret|password"
grep -rn -E "[0-9]{8,10}:[A-Za-z0-9_-]{30,}" lib/      # pola token bot Telegram

Periksa juga file konfigurasi yang ikut dibundel, misalnya file .env di folder assets.

Langkah 3: periksa hasil build, bukan hanya kode

Kode yang bersih belum tentu hasil build yang bersih. Buka APK release dan cari langsung:

unzip -o app-release.apk 'lib/arm64-v8a/libapp.so' -d apk/
strings apk/lib/arm64-v8a/libapp.so | grep -E "api\.telegram\.org/bot|sk-|AIza"

Kalau ada yang muncul, siapa pun yang memegang APK itu bisa melihatnya juga.

Langkah 4: hapus default, biarkan build tanpa rahasia tetap jalan

// Sebelum
static const String telegramBotToken = String.fromEnvironment(
  'TG_BOT_TOKEN',
  defaultValue: '<token asli di sini>',
);

// Sesudah: tanpa default
static const String telegramBotToken = String.fromEnvironment('TG_BOT_TOKEN');

Lalu pastikan kode yang memakainya berhenti dengan aman kalau nilainya kosong:

if (_botToken.isEmpty || _chatId.isEmpty) return;

Langkah 5: pindahkan rahasia ke server kalau memang harus rahasia

Kalau aplikasi perlu mengirim log ke Telegram tanpa membawa token, kirim lognya ke backend kamu sendiri, lalu backend yang meneruskan ke Telegram. Token cukup ada di server. Aplikasi hanya tahu alamat endpoint backend, yang memang boleh publik, dan endpoint itu bisa diberi batas jumlah request.

Kalau kamu tetap memakai --dart-define, anggap nilainya publik. Pakai token khusus dengan izin sekecil mungkin, bukan token pribadi atau token admin.

Langkah 6: jangan berharap riwayat git akan lupa

Menulis ulang riwayat git dengan alat seperti git filter-repo bisa menghapus token dari repo, tapi tidak dari salinan yang sudah di-clone orang lain dan tidak dari APK yang sudah beredar:

Token yang dihapus tetap ada di riwayat git
Token yang dihapus tetap ada di riwayat git

Karena itu langkah 1 tetap yang utama. Menulis ulang riwayat adalah pilihan tambahan, bukan pengganti.

Checklist sebelum rilis aplikasi HP

  • Tidak ada defaultValue berisi token, key, atau password di kode.
  • strings pada libapp.so dari APK release tidak menemukan token, key, atau URL yang memuat rahasia.
  • Nilai dari --dart-define diperlakukan sebagai publik.
  • Rahasia yang benar-benar rahasia hanya ada di server.
  • Token yang tetap ada di aplikasi punya izin sekecil mungkin dan bukan milik pribadi.
  • Kalau ada rahasia yang terlanjur bocor: dicabut dulu, baru kode diperbaiki.
  • Error di jalur penting seperti logout tidak ditelan diam-diam.

Yang belum dibuktikan demo ini

  • Demo ini memakai dart compile aot-snapshot, bukan build APK Flutter lengkap. Compiler AOT-nya sama, jadi string konstanta tertulis dengan cara yang sama, tapi saya tidak membongkar APK aplikasi klien untuk artikel ini.
  • Compiler bisa membuang string yang tidak dipakai. Dalam percobaan pertama, token yang hanya dicek isEmpty tidak muncul di hasil build. Ia baru muncul setelah dipakai untuk menyusun URL, seperti di logger aslinya. Jangan mengandalkan perilaku ini sebagai pengaman.
  • Obfuscation Dart tidak menyembunyikan isi string. Opsi --obfuscate mengganti nama class dan fungsi, bukan teks konstanta seperti token.
  • Artikel ini tidak membahas apakah token itu sudah dicabut. Pesan commit menyebut pencabutan lewat BotFather sebagai satu-satunya penutup. Itu pekerjaan di luar kode, dan tidak tercatat di repo.
  • Nama aplikasi dan proyek klien tidak disebut. Kode di artikel disederhanakan dari commit aslinya.

Dua perintah di langkah 3 cukup untuk tahu posisi kamu: hasil kosong berarti aman untuk pola yang dicari, hasil tidak kosong berarti rahasia itu sudah ada di HP pengguna.

Artikel terkait

Tulisan lain yang nyambung dengan topik ini.

Comments

Komentar akan tampil setelah disetujui.

Memuat komentar...