Layanan generator visual seperti dynamic QR code styler (misalnya arsitektur backend pada qr.jim.sh) sering memproses font TrueType (TTF) kustom yang diunggah pengguna untuk merender label teks di dalam kanvas vektor. Masalah muncul saat worker backend mengalami Out of Memory (OOM) crash berulang kali disertai lonjakan CPU hingga 100% ketika memproses berkas TTF tertentu.

Gejala Produksi: OOM Kill dan CPU Spike pada Worker Backend

Worker backend yang menangani antrean rendering grafis mendadak mati dengan kode exit 137 (OOM Killer). Monitoring metrik menunjukkan pola identik: satu worker menerima tugas render, alokasi heap melonjak tajam dalam hitungan detik hingga mencapai limit container, lalu proses dihentikan oleh kernel.

Analisis profiling CPU dan heap snapshot mengarah ke modul TTF parser internal, tepatnya pada fase pembacaan tabel glyf dan resolusi substitusi ligatur pada tabel GSUB. TTF parser tidak berhenti mengalokasi memori saat membaca koordinat kontur dari glyph berformat majemuk (composite glyphs).

Root Cause: Rekursi Glyph Table Parser Tanpa Bound Check

Format TTF mendefinisikan dua jenis glyph dalam tabel glyf: simple glyph dan composite glyph. Composite glyph merujuk ke satu atau beberapa sub-glyph ID lain melalui flag komponen (seperti flag MORE_COMPONENTS). Jika berkas font rusak atau dimodifikasi secara jahat (malformed TTF), hierarki composite glyph dapat membentuk referensi sirkular (glyph A memuat glyph B, yang memuat kembali glyph A) atau pohon rekursi dengan kedalaman tak terhingga.

Implementasi parser yang naif mengeksekusi rekursi pemanggilan fungsi tanpa membatasi kedalaman traversal (max depth) dan terus menerus mengalokasikan slice/buffer baru di heap pada setiap iterasi sub-glyph. Akibatnya terjadi alokasi memori tak terbatas hingga memicu OOM.

Perbaikan Konkret: Header Validation, Depth Limit, dan Buffer Reuse

Mitigasi memerlukan tiga lapisan proteksi: validasi header tabel maxp (Maximum Profile), pembatasan kedalaman rekursi maksimal komponen composite glyph, dan penggunaan kembali buffer koordinat.

package fontparser

import (
	"encoding/binary"
	"errors"
	"fmt"
)

const (
	MaxGlyphRecursionDepth = 10
	MaxComponentElements  = 64
)

type TTFParser struct {
	data           []byte
	glyfOffset     uint32
	glyfLength     uint32
	numGlyphs      uint16
	maxRecursion   uint16
	coordBuffer    []int16 // Buffer reuse
}

func (p *TTFParser) ValidateMaxpTable(maxpOffset uint32) error {
	if int(maxpOffset)+32 > len(p.data) {
		return errors.New("malformed TTF: offset maxp melebihi ukuran file")
	}

	p.numGlyphs = binary.BigEndian.Uint16(p.data[maxpOffset+4 : maxpOffset+6])
	p.maxRecursion = binary.BigEndian.Uint16(p.data[maxpOffset+28 : maxpOffset+30])

	if p.maxRecursion == 0 || p.maxRecursion > MaxGlyphRecursionDepth {
		p.maxRecursion = MaxGlyphRecursionDepth
	}
	return nil
}

func (p *TTFParser) ParseGlyph(glyphID uint16, depth uint16) error {
	if depth > p.maxRecursion {
		return fmt.Errorf("glyph table overflow: rekursi melebihi batas kedalaman %d", p.maxRecursion)
	}
	if glyphID >= p.numGlyphs {
		return errors.New("glyph ID di luar indeks tabel")
	}

	// Logika pembacaan offset glyph dari loca table diabaikan untuk keringkasan
	offset := p.glyfOffset + uint32(glyphID*10) 
	if offset+2 > uint32(len(p.data)) {
		return errors.New("offset glyph melebihi kapasitas data")
	}

	numberOfContours := int16(binary.BigEndian.Uint16(p.data[offset : offset+2]))

	if numberOfContours >= 0 {
		// Simple glyph: reuse buffer, hindari alokasi heap baru
		p.coordBuffer = p.coordBuffer[:0]
		return nil
	}

	// Composite glyph: numberOfContours < 0
	return p.parseComposite(offset+10, depth+1)
}

func (p *TTFParser) parseComposite(offset uint32, depth uint16) error {
	flags := uint16(0x0020) // MORE_COMPONENTS
	componentCount := 0

	for flags&0x0020 != 0 {
		componentCount++
		if componentCount > MaxComponentElements {
			return errors.New("komponen composite glyph melebihi limit wajar")
		}
		if offset+4 > uint32(len(p.data)) {
			return errors.New("truncated composite glyph header")
		}

		flags = binary.BigEndian.Uint16(p.data[offset : offset+2])
		subGlyphID := binary.BigEndian.Uint16(p.data[offset+2 : offset+4])
		offset += 4

		if err := p.ParseGlyph(subGlyphID, depth); err != nil {
			return err
		}
	}
	return nil
}

Unit Test untuk Payload TTF Malformed

Uji parsing dilakukan dengan menyusun byte array minimal yang memicu loop tak hingga (Glyph 0 memanggil Glyph 0 kembali) untuk memverifikasi penanganan error berjalan semestinya.

package fontparser

import "testing"

func TestParseGlyph_DetectsCircularRecursion(t *testing.T) {
	// Simulasi raw byte composite glyph yang memanggil dirinya sendiri (loop)
	fakeData := make([]byte, 64)
	// Set numberOfContours = -1 (Composite Glyph) pada offset 0
	fakeData[0] = 0xFF
	fakeData[1] = 0xFF
	// Set component flags = 0x0020 (MORE_COMPONENTS) dan subGlyphID = 0
	fakeData[10] = 0x00
	fakeData[11] = 0x20
	fakeData[12] = 0x00
	fakeData[13] = 0x00

	parser := &TTFParser{
		data:         fakeData,
		glyfOffset:   0,
		glyfLength:   64,
		numGlyphs:    1,
		maxRecursion: 5,
		coordBuffer:  make([]int16, 0, 128),
	}

	err := parser.ParseGlyph(0, 0)
	if err == nil {
		t.Fatalf("harapan error glyph table overflow, tetapi parsing lolos tanpa error")
	}

	expectedErr := "glyph table overflow: rekursi melebihi batas kedalaman 5"
	if err.Error() != expectedErr {
		t.Errorf("pesan error mismatch. dapat: %v, ingin: %v", err.Error(), expectedErr)
	}
}

Ringkasan

Parsing berkas font biner dari pengguna tidak boleh berasumsi data selalu valid. Validasi metadata tabel maxp, pembatasan kedalaman rekursi composite glyph, serta buffer reuse terbukti menghentikan konsumsi memori tak terhingga dan mencegah CPU spike pada worker render.