Декодирование Base64 в Go: полное руководство
В ответе API прячется длинная строка, которая делает вид, что это обычное значение, хотя на самом деле это файл, токен, изображение или сообщение от системы, на три года старше вашей. Где-то в строках ваших логов, записях базы данных и JSON-пелодах base64-строки мелькают постоянно: стандартный алфавит из 64 букв, то с плюсом и слэшем, то с дефисом и подчёркиванием, а иногда - с двумя знаками равенства, примостившимися в конце, как подпись.
Домашняя страница этого сайта уже объясняет сам формат: 64 печатных символа, по 6 бит на каждого, четыре символа на три входных байта и заполнение, чтобы доделать дело. Так что статья пойдёт сразу к той половине работы, где принимаются интересные решения: к раскрытию этих строк в Go. Хорошая новость в том, что Go - прекрасное место для этого. Один пакет стандартной библиотеки, ноль зависимостей, декодер, строгий по умолчанию, но снисходительный к переводам строк, и сообщения об ошибках, которые указывают на тот самый байт, где всё пошло не так.
Что идёт в комплекте с Go
Всё, что вам нужно, уже лежит в стандартной библиотеке. Пакет называется encoding/base64, в его исходном файле до сих пор стоит шапка с копирайтом 2009 года, из года рождения языка, а включать не нужно никакого расширения, тянуть - никакого модуля, переключать - никакой настройки. Если go version на вашей машине печатает что-нибудь - вы уже владеете всем инструментом целиком.
На момент написания статьи самый свежий выпуск - Go 1.27.1, вышедший 1 сентября 2026 года, а второй поддерживаемой линией идёт Go 1.26 (сейчас это 1.26.8). Base64 API в обеих линиях идентичен, и благодаря обещанию совместимости Go 1 программа, которая сегодня декодирует base64, будет делать ровно то же самое в каждом будущем выпуске. Сам Go можно взять из официальных tar-архивов на go.dev/dl (что-нибудь вроде go1.27.1.linux-amd64.tar.gz, которое распаковывается в /usr/local), из пакетного менеджера вашего дистрибутива (sudo apt install golang-go на системах на базе Ubuntu) или через обёртку golang.org/dl, если вы любите держать несколько версий Go бок о бок.
Как только Go установлен, go doc encoding/base64 печатает весь API читабельной колонкой, и это самый быстрый способ освежить память. Единственная добавка, которую эта статья использует где-нибудь, - golang.org/x/text для устаревших кодировок; устанавливается он командой go get golang.org/x/text. Он появляется один раз, в собственном разделе, а всё остальное - чистая стандартная библиотека.
Ваше первое декодирование
Девяносто процентов декодирующей жизни в Go - это один метод типа Encoding:
func (enc *Encoding) DecodeString(s string) ([]byte, error)
Дайте ему base64-строку, и он отдаст байты, которые она представляет, плюс ошибку, если вход повёл себя не так:
package main
import (
"encoding/base64"
"fmt"
)
func main() {
decoded, err := base64.StdEncoding.DecodeString("TWFu")
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(string(decoded)) // Man
}
Две вещи в этой сигнатуре заслуживают того, чтобы их запомнили. Первая: результат - это []byte, а не строка, потому что байты, которые вы распаковываете, могут быть совершенно валидным base64 и совершенно ужасным текстом: заголовок PNG, сжатый архив, бинарный протокол. Заверните их в string(...) только тогда, когда знаете, что пелод - текст. Вторая: метод всегда возвращает два значения. Ошибка nil означает, что строка была чистым base64; ненулевая ошибка означает, что вход был сломан где-то по пути, и срез байтов, который вы получили, может оказаться частичным результатом, а не пустым. Вы увидите обе стороны этого поведения в разделе об ошибках ниже.
Четыре декодера, один вопрос: какой алфавит?
Go поставляется с четырьмя готовыми значениями Encoding, и выбор правильного - первое настоящее решение в каждом декодировании. Таблица ниже - карта рассадки:
| Переменная | Алфавит | Заполнение | Где вы с ним столкнётесь |
|---|---|---|---|
StdEncoding |
A-Z a-z 0-9 + / |
= |
MIME-письма, data URL, HTTP Basic auth, файлы PEM, обычный JSON |
URLEncoding |
A-Z a-z 0-9 - _ |
= |
пути и запросы URL, имена файлов |
RawStdEncoding |
A-Z a-z 0-9 + / |
нет | стандартный base64 без заполнения от компактных производителей |
RawURLEncoding |
A-Z a-z 0-9 - _ |
нет | сегменты JWT, компактные идентификаторы API |
Самый быстрый способ выбрать - посмотреть на сами данные. Строка, содержащая + или /, может быть только строкой со стандартным алфавитом, значит, ей нужен один из двух Std-декодеров. Строка, содержащая - или _, - это URL-безопасный вариант из RFC 4648, значит, ей нужен один из двух URL-декодеров. Потом проверьте хвост: знаки = в конце означают вариант с заполнением, а их отсутствие - вариант Raw. Вот как ощущается неправильный выбор:
decoded, err := base64.StdEncoding.DecodeString("P29_")
// err: illegal base64 data at input byte 3
// подчёркивание не входит в стандартный алфавит, поэтому
// декодер останавливается на последнем символе, которого он не узнаёт
decoded, err = base64.URLEncoding.DecodeString("P29_")
// decoded - три байта 0x3f 0x6f 0x7f, err равен nil
Если вы декодируете данные от производителя, который определил собственный 64-символьный алфавит, base64.NewEncoding("...64 chars...") соберёт для вас декодер под него. Алфавит должен содержать ровно 64 уникальных значения байтов и не должен содержать перевода строки - иначе функция вызывает панику - а вот то, что документация требует исключить из алфавита символ заполнения, функция не проверяет: алфавит с '=' принимается без паники. В повседневной работе вам он почти не понадобится, но он есть, и это единственный способ декодировать частную схему.
Проблема терпимости: какой вход Go принимает?
Каждому base64-декодеру приходится принимать одно неприятное решение: сколько мусора он готов проглотить? Ответ Go - аккуратно проведённая черта. На снисходительной стороне декодер пропускает возвраты каретки и переводы строк в любом месте входа, так что строка, перенесённая почтовым клиентом или PEM-утилитой на много строк, декодируется без какой-либо предобработки:
decoded, err := base64.StdEncoding.DecodeString("T\nW\nF\r\nu")
// decoded - это «Man», err равен nil
// каждый \r и \n в строке просто игнорировался
На строгой стороне всё остальное - вне закона. Пробел, табуляция, символ нулевой ширины, скопированный из PDF, случайное двоеточие из заголовка: в тот момент, когда декодер встречает символ, которого нет в алфавите и который не перевод строки, он останавливается и сообщает смещение. При этом он сохраняет всё, что уже успел декодировать:
decoded, err := base64.StdEncoding.DecodeString("TWFu junk")
// decoded - это «Man» (часть до пробела),
// в err: illegal base64 data at input byte 4
Это сочетание удивляет: неудачное декодирование всё равно может отдать вам пригодный полурезультат. Фича это или опасность - зависит от вас; суть в том, что err == nil - единственное условие, при котором данные полные.
Заполнение имеет собственные правила, и они отличаются у вариантов с заполнением и raw. Декодеры с заполнением работают группами: группа - это либо четыре настоящих символа, либо два настоящих символа, за которыми стоит ==. Один символ сам по себе никогда не бывает полной группой, поэтому "T" падает, и "TWF" тоже падает, потому что трём символам нужен один знак заполнения, которого нет. Raw-декодеры отбрасывают требование заполнения, но они всё равно не примут длину, при которой группе не хватает трёх из четырёх символов, поэтому "T" падает и там, а вот "TW" спокойно декодируется в один байт.
base64.StdEncoding.DecodeString("T") // ошибка на байте 0 входа
base64.StdEncoding.DecodeString("TWF") // ошибка на байте 0 входа
base64.RawStdEncoding.DecodeString("TW") // 1 байт, без ошибки
base64.StdEncoding.DecodeString("TWFu====") // «Man» плюс ошибка на байте 4
Есть ещё один переключатель настроения: Strict(), добавленный в Go 1.8. В строгом режиме декодер требует канонической формы из раздела 3.5 RFC 4648: неиспользуемые хвостовые биты последней группы должны быть нулями. Обычный режим этого не замечает, потому что эти биты просто никогда не используются, так что "Qm==" декодируется в байт B без единой жалобы. Строгий режим вместо этого отказывает:
decoded, err := base64.StdEncoding.DecodeString("Qm==")
// decoded - это «B», err равен nil (хвостовые биты были отброшены)
decoded, err = base64.StdEncoding.Strict().DecodeString("Qm==")
// в err: illegal base64 data at input byte 2
Обратите внимание: даже в строгом режиме переводы строк всё равно пропускаются, как и указывает документация. Используйте Strict(), когда вы говорите на протоколе, которым важна каноническая кодировка, или когда хотите отклонять небрежных производителей, а не молча проглатывать их биты.
Ошибки, которые говорят, где
Каждый сбой в этом пакете приходит в виде конкретного, осматриваемого значения. Когда во входе есть что-то неведомое алфавиту, или заполнение неправильное, декодер возвращает base64.CorruptInputError, и в его сообщении есть байтовое смещение проблемы:
type CorruptInputError int64
func (e CorruptInputError) Error() string {
return "illegal base64 data at input byte " + strconv.FormatInt(int64(e), 10)
}
Это смещение - разница между «что-то сломалось» и «4102-й символ этой строки на 900 килобайт - табуляция, которая забрела из буфера обмена». Поймайте его привычной идиомой Go:
package main
import (
"encoding/base64"
"errors"
"fmt"
)
func main() {
_, err := base64.StdEncoding.DecodeString("TWF$")
var corrupt base64.CorruptInputError
if errors.As(err, &corrupt) {
fmt.Printf("bad byte at offset %d: %v\n", int(corrupt), err)
// bad byte at offset 3: illegal base64 data at input byte 3
return
}
fmt.Println("not a corrupt-input error:", err)
}
Вот таблица симптомов для тех входов, которые больше всех вводят в заблуждение:
| Вход (StdEncoding) | Результат | Почему |
|---|---|---|
TWF$ |
ошибка на байте 3 | $ нет в алфавите |
T |
ошибка на байте 0 | один символ никогда не бывает полной группой |
TWF |
ошибка на байте 0 | трем символам нужен один =, которого нет |
TWFu junk |
Man плюс ошибка на байте 4 |
пробел - не перевод строки, поэтому декодирование останавливается там |
TWFu\t |
Man плюс ошибка на байте 4 |
табуляции не пропускаются, только \r и \n |
T\nW\nF\nu |
Man, без ошибки |
переводы строк игнорируются где угодно |
==== |
ошибка на байте 0 | заполнение в начале группы недействительно |
(пустая строка) |
пустой результат, без ошибки | ноль байтов base64 декодируется в ноль байтов |
Один практический совет: когда декодирование падает в продакшене, записывайте в лог смещение и небольшой участок вокруг него. В девяноста процентах случаев «сломанный» байт - это пробельный символ, который транспорт, буфер обмена или просмотрщик PDF подмешал в строку, а решение - обрезать или вычистить, а не перепроектировать.
Открытие файлов
Base64-файлы - это просто текстовые файлы, содержащие base64, так что обычные файловые инструменты Go применимы. Для файла, который спокойно помещается в памяти, прочитайте его целиком и декодируйте строку:
package main
import (
"encoding/base64"
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("payload.b64")
if err != nil {
fmt.Println("open failed:", err)
return
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
fmt.Println("read failed:", err)
return
}
decoded, err := base64.StdEncoding.DecodeString(string(raw))
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println("decoded", len(decoded), "bytes")
}
Для больших файлов лучше подходит потоковая обработка, и она использует другую половину API пакета: NewDecoder оборачивает любой io.Reader в reader, декодирующий base64, так что можно трубой передать файл в файл, не держав весь пелод в памяти:
in, err := os.Open("payload.b64")
if err != nil {
panic(err)
}
defer in.Close()
dec := base64.NewDecoder(base64.StdEncoding, in)
out, err := os.Create("payload.bin")
if err != nil {
panic(err)
}
defer out.Close()
written, err := io.Copy(out, dec)
if err != nil {
panic(err)
}
fmt.Println("wrote", written, "bytes")
Есть и среднее решение, если хочется избежать дополнительной аллокации, которую делает DecodeString: Decode пишет в целевой буфер, которым управляете вы. Размер задаётся через DecodedLen - он возвращает максимальное количество выходных байтов для заданной длины входа:
raw, err := os.ReadFile("payload.b64")
if err != nil {
panic(err)
}
buf := make([]byte, base64.StdEncoding.DecodedLen(len(raw)))
n, err := base64.StdEncoding.Decode(buf, raw)
if err != nil {
panic(err)
}
data := buf[:n] // фактический декодированный размер
fmt.Println(len(data), "bytes")
Только будьте осторожны с этим последним: Decode верит, что вы правильно зададите размер буфера. Если он слишком мал, метод не вернёт ошибку; он вызовет панику - выход индекса за границы. DecodedLen - вот число, которое нужно использовать, а не len(raw).
Командный base64 для Go
В Unix-системах утилита base64 поставляется вместе с coreutils, а Go не даёт эквивалентного бинарника. Идиоматичный ответ в мире Go - это не пакет, который вы устанавливаете, а программа, которой владеете вы: маленький инструмент командной строки, построенный вокруг encoding/base64, пакета flag и стандартного ввода. Вот готовый, примерно на сорок строк: он декодирует всё, что ему передают по трубе, и пишет сырые байты наружу:
package main
import (
"encoding/base64"
"flag"
"fmt"
"io"
"os"
)
func main() {
urlSafe := flag.Bool("url", false, "use the URL-safe alphabet")
flag.Parse()
enc := base64.StdEncoding
if *urlSafe {
enc = base64.URLEncoding
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}
decoded, err := enc.DecodeString(string(raw))
if err != nil {
fmt.Fprintln(os.Stderr, "decode failed:", err)
os.Exit(1)
}
os.Stdout.Write(decoded)
}
Скомпилируйте его один раз - go build -o b64 . - и он станет кроссплатформенным декодером, который можно вставить в Makefile, CI-конвейер или функцию оболочки: printf 'TWFu' | ./b64 напечатает Man, а ./b64 -url < token.b64 > token.bin развернёт URL-безопасный токен в файл. Две черты этого дизайна заслуживают внимания. Поскольку он читает весь stdin до декодирования, перенесённый во ввод с переводами строк декодируется без проблем - спасибо терпимости декодера к переводам строк. А поскольку он завершается со статусом 1 на неверном вводе и пишет жалобу в stderr, он ведёт себя как инструмент в конвейере, а не как сценарий, который извиняется. В этом и состоит всё искусство Go CLI: один пакет, один флаг, стандартный ввод, стандартный вывод и код выхода.
URL-безопасное декодирование
URL-безопасный вариант существует потому, что стандартный алфавит сталкивается с грамматикой URL: + в строках запросов часто читается как пробел, а / начинает новый сегмент пути, так что стандартная base64-строка, встроенная в URL, должна процентно экранироваться посимвольно, что парсится медленно и некрасиво читается. Альтернативный алфавит RFC 4648 меняет + и / на - и _, оба которых законны без экранирования в путях URL, запросах и именах файлов.
В Go переключение - это просто другая переменная декодера. Если ваши данные URL-безопасные и с заполнением, используйте URLEncoding; если URL-безопасные и без заполнения - RawURLEncoding. Классический случай - идентификатор, который живёт в URL или имени файла:
decoded, err := base64.RawURLEncoding.DecodeString("-w9n")
// decoded - три байта 0xfb 0x0f 0x67
// дефис и подчёркивание - часть URL-безопасного алфавита,
// поэтому RawURLEncoding справляется там, где StdEncoding упал бы
Где вы встретите его в настоящем Go-коде: сегменты JWT (о них в следующем разделе), непрозрачные идентификаторы, которые системы генерируют и хранят в URL, имена файлов, которые не должны ломать веб-сервер или облачное хранилище объектов, и любой API, пообещавший «base64url» в своей документации. Одно предупреждение: URL-безопасность - это договорённость между производителем и потребителем, а не свойство данных. Если в строке есть + или /, она не URL-безопасная, точка, и никакое количество повторов с URL-декодером не поможет. Сначала смотрите на символы, потом выбирайте декодер.
Заглядываем внутрь JWT
JSON Web Token - это три base64url-сегмента, разделённые точками: заголовок, пелод с claims и подпись, без заполнения ни в одном из сегментов. Это делает JWT одной из самых частых вещей, которые вы будете декодировать в Go, причём заголовок и пелод читаются без какого-либо ключа - об этом стоит помнить и при отладке, и при проверках безопасности:
package main
import (
"encoding/base64"
"fmt"
"log"
"strings"
)
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parts := strings.Split(token, ".")
if len(parts) != 3 {
log.Fatal("not a JWT: expected three dot-separated parts")
}
for i, name := range []string{"header", "payload"} {
plain, err := base64.RawURLEncoding.DecodeString(parts[i])
if err != nil {
log.Fatalf("bad %s: %v", name, err)
}
fmt.Printf("%s: %s\n", name, plain)
}
// header: {"alg":"HS256","typ":"JWT"}
// payload: {"name":"Go Developer","sub":"1234567890"}
}
Обратите внимание на выбор декодера: RawURLEncoding, а не StdEncoding. Сегменты JWT используют URL-безопасный алфавит и не несут заполнения, а сегмент, длина которого на один или два символа меньше кратного четырём, заставит декодер с заполнением упасть в самом конце - это сбивающая с толку ошибка, за которой трудно угнаться. Сегмент подписи без ключа не прочитать, и доверять чему-либо на основании одного пелода не стоит, потому что ничего не мешает клиенту подделать два первых сегмента. Когда нужна проверка, используйте поддерживаемую библиотеку. Де-факто это github.com/golang-jwt/jwt/v5 (устанавливается командой go get github.com/golang-jwt/jwt/v5):
package main
import (
"fmt"
"log"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte("hmac-secret")
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
log.Fatal("token rejected:", err)
}
claims, _ := parsed.Claims.(jwt.MapClaims)
fmt.Println("subject:", claims["sub"])
}
Две детали библиотеки стоит знать. Первая: base64url-кодирование и декодирование трёх сегментов происходит внутри, так что при подписании или проверке вы никогда не трогаете encoding/base64 напрямую. Вторая: библиотека v5 отказывается принимать токены с alg=none, если вы явно не передадите её константу UnsafeAllowNoneSignatureType, - она защищает от классической ошибки «принят неподписанный токен».
Data URL
Data URL - это URL, чей пелод - сами данные. Синтаксис, по RFC 2397, выглядит так: data:[mediatype][;base64],data: необязательный тип носителя, необязательный флаг ;base64, запятая, а затем содержимое. Когда флаг ;base64 присутствует, содержимое - это стандартный base64, поэтому data URL и эта статья делят раздел. Браузеры используют их, чтобы встраивать изображения и шрифты прямо в HTML и CSS, чтобы странице нужен был на один запрос меньше:
<img src="data:image/png;base64,iVBORw0KGgo=" alt="pixel">
В стандартной библиотеке Go нет помощника для data URL, но формат прост и его легко распарсить вручную с strings - именно так делают большинство Go-программ:
package main
import (
"encoding/base64"
"fmt"
"strings"
)
func main() {
url := "data:image/png;base64,iVBORw0KGgo="
if !strings.HasPrefix(url, "data:") {
fmt.Println("not a data URL")
return
}
rest := url[len("data:"):]
comma := strings.Index(rest, ",")
if comma == -1 {
fmt.Println("missing comma")
return
}
meta := rest[:comma] // image/png;base64
encoded := rest[comma+1:] // iVBORw0KGgo=
if !strings.HasSuffix(meta, ";base64") {
fmt.Println("this variant is percent-encoded, not base64")
return
}
mediaType := strings.TrimSuffix(meta, ";base64")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(mediaType, "carries", len(decoded), "bytes")
}
Три подводные камни, о которых стоит помнить. Первая: флаг ;base64 необязателен, и без него пелод - процентно закодированный ASCII, а не base64, поэтому проверяйте суффикс, прежде чем звать декодер. Вторая: когда тип носителя опущен, по умолчанию действует text/plain;charset=US-ASCII, что для изображений почти никогда не важно, но удивляет тех, кто парсит другое содержимое. Третья: data URL - это трюк для маленьких пелодов: сам RFC говорит, что схема полезна только для коротких значений, а увеличение размера на 33 процентов от base64 превращает логотип на 500 килобайт в строку на 666 килобайт, приклеенную к вашему HTML, которую нельзя ни закэшировать, ни отправить по ссылке. Используйте их для иконок и миниатюр, а не для видео.
Работа с HTTP и API
Самое частое декодирование в Go веб-сервисах - поле JSON-тела: форма загрузки, ответ API или вебхук передают вам строку, которая на самом деле является файлом. Размаршалируйте её в структуру, затем декодируйте поле:
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type payload struct {
Avatar string `json:"avatar"`
}
func main() {
body := []byte(`{"avatar": "iVBORw0KGgo="}`)
var p payload
if err := json.Unmarshal(body, &p); err != nil {
fmt.Println("bad JSON:", err)
return
}
img, err := base64.StdEncoding.DecodeString(p.Avatar)
if err != nil {
fmt.Println("bad avatar:", err)
return
}
fmt.Println("avatar is", len(img), "bytes")
}
Если ваш API принимает и стандартные, и URL-безопасные строки, прагматичный паттерн - попробовать один декодер и, если он упадёт с CorruptInputError где-то в конце, попробовать другой, прежде чем сдаваться. Не оттанцовывайте этот танец больше одного раза и никогда не делайте «сорвать знаки равенства и понадеяться» общей стратегией.
Для HTTP Basic аутентификации вы вообще ничего не декодируете, потому что Go делает это за вас. Request.BasicAuth, доступный с Go 1.4, разбирает заголовок Authorization за вас и возвращает имя пользователя и пароль, предварительно прогнав стандартный base64-декодер по паре user:pass, которую определяет RFC 2617:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "alice" || pass != "s3cret" {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "hello", user)
})
http.ListenAndServe(":8080", mux)
}
Помните, что Basic auth - это аутентификация, а не защита: заголовок - base64, а не шифр, поэтому он должен путешествовать только по HTTPS. Если вы на стороне клиента, зеркальный вызов - req.SetBasicAuth(user, pass), который строит тот же заголовок для вас стандартным кодировщиком.
Одна защитная привычка для обработчиков API: ограничьте тело до того, как его декодировать, через http.MaxBytesReader или эквивалентную проверку длины. Base64-строка декодируется примерно в три четверти собственной длины, так что лимит тела в N байтов держит декодированный результат под N байтов, и память остаётся ограниченной, что бы ни прислал вредоносный клиент. Декодирование тела без ограничений - классический вектор исчерпания памяти, потому что атакующий контролирует, сколько мегабайт текста он может превратить в бинарные данные.
Устаревшие кодировки
Декодирование base64 даёт вам байты, и в современных системах эти байты почти всегда UTF-8, в таком случае string(decoded) - и есть вся история. Но base64 - формат старый, и многое из него произведено системами, которые использовали Windows-1252, ISO-8859-1, Shift JIS или какую-нибудь другую однобайтовую или двухбайтовую устаревшую кодировку. Если производитель так сделал, декодированные вами байты - не валидный UTF-8, и Go не будет делать вид, что это так: в местах, где последовательность сломана, он покажет вам символы замены.
Ответ Go - модуль golang.org/x/text, который превращает байты с устаревшей кодировкой в UTF-8 (и обратно) для распространённых кодировок. Место для преобразования - сразу после декодирования, и для этого нужен один вызов функции:
package main
import (
"encoding/base64"
"fmt"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func main() {
// «Café», сохранённый в Windows-1252 устаревшим инструментом,
// затем base64-закодированный для передачи
encoded := "Q2Fm6Q=="
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), raw)
if err != nil {
fmt.Println("charset conversion failed:", err)
return
}
fmt.Println(string(utf8)) // Café
}
В модуле есть по сабпакету на семейство кодировок: charmap - для однобайтовых таблиц Windows и ISO, japanese - для Shift JIS и EUC-JP, korean - для EUC-KR, simplifiedchinese - для GB18030 и traditionalchinese - для Big5. Эмпирическое правило: преобразуйте только тогда, когда вы действительно знаете кодировку производителя, потому что второе преобразование байтов UTF-8 не падает громко - оно просто калечит текст. Когда сомневаетесь, относитесь к пелоду как к байтам и дайте downstream-потребителю решить.
Потоковая обработка и декодирование чанками
NewDecoder вы уже видели в разделе о файлах; вот что делает его достойным собственного раздела. Это настоящий поточный адаптер: он забирает из базового reader ровно столько, сколько нужно, декодирует на месте и возвращает CorruptInputError в тот момент, когда поток ломается. Весь поток может весить терабайты; память, которую вы держите, - это ваш буфер и тот вывод, который вы пишете. Два распространённых паттерна потребления: io.ReadAll для маленьких потоков и io.Copy для всего остального:
small, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, r))
// отлично для конфигурационного блоба или маленького вложения
w, err := io.Copy(out, base64.NewDecoder(base64.StdEncoding, r))
// отлично для видео, tar-архива или задачи восстановления
С Go 1.22 в пакете есть ещё AppendDecode, который декодирует в буфер, который вы переиспользуете, вместо того чтобы аллоцировать новый срез на каждый вызов. Это инструмент для горячих путей, где в цикле декодируется много чанков, - например, процессор строк или декодер протокола:
var buf []byte
for _, chunk := range chunks {
buf, err = base64.StdEncoding.AppendDecode(buf, chunk)
if err != nil {
return err
}
process(buf)
}
Метод дописывает декодированный чанк к тому, что уже хранится в buf, и возвращает расширенный срез, наращивая базовый массив по мере необходимости. В установившемся режиме, когда буфер уже вырос до нужного размера, он выполняет ноль аллокаций на чанк, что чётко видно в бенчмарке. Если ваша нагрузка - «декодировать один раз, изредка», DecodeString - более простой выбор; если «декодировать тысячи раз в тесном цикле», тянитесь за AppendDecode.
Держим всё в безопасности
Несколько заметок о безопасности, специфичных для того, как Go-программы на самом деле используют этот пакет. Первая: base64 - это кодирование, а не шифрование. Base64-строку может прочитать любой, у кого есть инструменты разработчика в браузере, так что «мы base64-ируем пароль перед отправкой» - не мера безопасности, а удобство передачи. Конфиденциальность должна приходить из TLS, а не из алфавита.
Вторая: ограничивайте вход. Декодированный размер base64-строки не превышает DecodedLen от её длины, так что проверяйте это число против лимита до аллокации, и оборачивайте тела запросов ограничением размера, прежде чем что-либо коснётся декодера. Обе проверки - по одной строке, и вместе они превращают неограниченное декодирование в ограничное.
Третья: определитесь с позицией по небрежному входу. Обычный режим молча отбрасывает неиспользуемые хвостовые биты последней группы, что означает: две разные строки могут декодироваться в одни и те же байты. Для большинства данных это неважно. Для всего, что является частью протокола, подписанного сообщения или значения, которое сравнивается или хранится, Strict() - консервативный выбор, потому что он делает каноническую форму единственной принимаемой.
Четвёртая: следите за тем, куда идут декодированные байты. Если декодированное значение становится именем файла, путём, SQL-фрагментом или аргументом команды, base64-слой не защитил вас ни от чего: теперь эти байты - недоверенный вход вашей программы, и обычные правила санитизации применяются ровно так же, как для любых других пользовательских данных.
Насколько быстр декодер?
Base64 в Go быстрый, и остаётся быстрым на больших данных, потому что реализация - это простой цикл табличного поиска без рефлексии и без аллокаций на символ. На недавнем настольном процессоре под Go 1.26 строка на 500 байт декодируется примерно за четверть микросекунды с одной аллокацией, что даёт порядка двух гигабайт в секунду. Мегабайт base64 декодируется заметно меньше чем за миллисекунду; гигабайт - заметно меньше чем за секунду. Числа двигаются вместе с аппаратным обеспечением, но форма остаётся: декодирование base64 почти никогда не бывает узким местом; им обычно бывают сеть или диск вокруг.
Если вы в горячем цикле, за чем нужно следить, - это профиль аллокаций. DecodeString аллоцирует срез результата на каждый вызов. Decode с заранее размерённым целевым буфером и AppendDecode с переиспользуемым буфером в установившемся режиме полностью избегают этой аллокации. Для декодирования, которое происходит пару раз за запрос, это неважно; для декодирования, которое происходит несколько миллионов раз в секунду, это разница между ровным профилем памяти и жужжащим сборщиком мусора.
Краткая история пакета
Пакет base64 - одна из старейших частей стандартной библиотеки Go. В шапке копирайта его исходного файла стоит 2009, год создания языка, а пакет входит в стандартную библиотеку с самого первого стабильного выпуска - Go 1.0, март 2012. Это значит, что DecodeString, который вы вызываете сегодня, - это тот же API с тем же поведением, на который Go-программы зовут уже больше десяти лет.
Рост с тех пор был скромным и полезным. Go 1.5, август 2015, добавил значения RawStdEncoding и RawURLEncoding без заполнения, открыв дверь к компактным строкам в стиле JWT. Go 1.8, февраль 2017, добавил Strict(), дав протоколам способ требовать канонического входа. Go 1.22, февраль 2024, добавил AppendDecode и AppendEncode во всё семейство base-кодировок и подтянул WithPadding так, чтобы он отклонял вздорные аргументы. И по состоянию на сентябрь 2026, с Go 1.27.1 как самым свежим выпуском и Go 1.26 как другой поддерживаемой линией, API - ровно то, что описано в этой статье: четыре готовые кодировки, поточный декодер, строгий режим и append-семейство для производительности.
Более глубокий факт - обещание совместимости. Гарантия Go 1 означает, что пакет будет вечно принимать и отклонять одни и те же входы, так что декодер, который вы напишете в этом году под формат данных, произведённый в 2015 году, будет работать и дальше. Для формата, такого старого и такого скучного, это лучшая новость из возможных.
Вещи, которые вас удивят
После некоторого времени в Go вас перестаёт удивлять base64, но в первые пару раз несколько этих фактов бьют больно, так что вот они:
- Декодер пропускает
\rи\nгде угодно во входе, но не пробел, не табуляцию и не пробел нулевой ширины. Эта снисходительность намеренна: она существует, чтобы корректно работал MIME-ввод, перенесённый на строки, и останавливается ровно там, где останавливается спецификация. - Неудачное декодирование всё равно может вернуть настоящие данные. Частичный результат - это всё, что декодировано до плохого байта, и ошибка приходит рядом с ним, а не вместо него.
CorruptInputError- это буквально простоint64с прикреплённым методом. «Ошибка» - это смещение, а сообщение строится по требованию.DecodeиEncodeоба верят, что вы правильно размерите их целевые буферы. Дайте им слишком маленький буфер - не получите ошибку; получите панику.- Один символ - не валидный вход ни для одной из четырёх встроенных кодировок. Один base64-символ несёт шесть бит, а байту нужно восемь, так что в одном символе нет полной группы - с заполнением или без.
- По состоянию на август 2026 более 244 000 публичных пакетов на pkg.go.dev имеют
encoding/base64среди своих импортов. Он тихо является одним из самых востребованных пакетов во всей экосистеме.
Где декодирование идёт не так
Это ошибки декодирования, которые постоянно всплывают в кодовых базах Go, примерно в том порядке, в котором они появляются в тредах поддержки:
- Выбрать
StdEncodingдля URL-безопасных данных (или наоборот). Симптом - ошибка на первом-,_,+или/, а решение - посмотреть на строку, прежде чем выбирать декодер. - Вставить строку из терминала, письма или PDF, откуда в неё затесались пробелы, табуляции или артефакты концов строк. Go пропускает настоящие переводы строк, но пробел посреди строки - это повреждённый байт, и смещение в ошибке укажет прямо на него.
- Забыть, что результат - это
[]byte. Вывести его в сыром виде - получите список чисел, а чтобы передать его функции, ожидающей строку, нужна конвертацияstring(...). - Проверить ошибку, но всё равно использовать частичные данные. Полузакодированный префикс - настоящий, но это не пелод, и код, который так считает, падает в продакшене, выдавая данные ровно в половину нужной длины.
- Задавать размер буфера
Decodeчерезlen(src)вместоDecodedLen(len(src)). Первый размер неверен в другом направлении, чем вам хотелось бы, а паника, которую он вызывает, случается только на больших входах, что делает её любимцем staging-сред. - Думать, что сегменты JWT несут заполнение. Не несут, и декодер с заполнением падает на последнем символе с ошибкой, которая читается как загадка. Используйте
RawURLEncoding. - Думать, что весь пробел пропускается. Не весь. Пропускаются только два символа перевода строки, а «пробел» в буфере обмена - семья гораздо больше.
- Двойное декодирование, или, наоборот, отказ декодировать дважды, когда значение - base64 от base64 (файл, приложенный к письму, которое само было вложением). Проверка - один круговой путь: декодируйте один раз, посмотрите, похож ли результат всё ещё на base64, и только тогда декодируйте снова.
Другая половина работы
Вот и вся сторона декодирования истории: один пакет, четыре готовых декодера, поточный декодер для больших данных, строгий режим для придирчивых протоколов и сообщения об ошибках, которые говорят, на каком байте всё пошло не так. Выучите правила терпимости, выбирайте декодер, глядя на символы, ограничивайте вход - и base64 в Go становится скучной, предсказуемой утилитой с нулём зависимостей, какой она и задумывалась.
Когда работа переворачивается и вашей Go-программе нужно производить base64-строки, а не открывать их, связанная статья о кодировании Base64 в Go подробно разбирает эту сторону: одно-методное API кодировщика, вызов Close, который тихо сжирает ваши последние два байта, перенос строк для MIME и как четыре кодировки ложатся на каналы, по которым они путешествуют.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в Go: полное руководство