Let's Go Further parse کردن درخواست‌های JSON › محدود کردن ورودی‌ها
قبلی · فهرست مطالب · بعدی
فصل ۴.۳.

محدود کردن ورودی‌ها

تغییراتی که در فصل قبل برای برخورد با JSON نامعتبر و bad requestهای دیگر انجام دادیم، یک قدم بزرگ در مسیر درست بود. اما هنوز چند کار دیگر هست که می‌توانیم انجام دهیم تا پردازش JSON ما حتی robustتر شود.

یکی از این کارها برخورد با fieldهای ناشناخته است. برای مثال، می‌توانید یک request شامل field ناشناخته rating به createMovieHandler بفرستید، به این شکل:

$ curl -i -d '{"title": "Moana", "rating":"PG"}' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:51:50 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:0 Runtime:0 Genres:[]}

دقت کنید که این request بدون هیچ مشکلی کار می‌کند؛ هیچ خطایی وجود ندارد که به client اطلاع دهد field مربوط به rating توسط application ما شناخته نشده است. در بعضی سناریوها، نادیده گرفتن بی‌صدای fieldهای ناشناخته دقیقا همان رفتاری است که می‌خواهید، اما در مورد ما بهتر است بتوانیم client را از مشکل آگاه کنیم.

خوشبختانه json.Decoder در Go یک setting به نام DisallowUnknownFields() فراهم می‌کند که می‌توانیم هنگام وقوع این حالت از آن برای تولید خطا استفاده کنیم.

مشکل دیگری که داریم این است که json.Decoder برای پشتیبانی از streamهای داده JSON طراحی شده است. وقتی Decode() را روی request body صدا می‌زنیم، در واقع فقط اولین مقدار JSON را از body می‌خواند و decode می‌کند. اگر بار دوم Decode() را صدا بزنیم، مقدار دوم را می‌خواند و decode می‌کند، و همین‌طور ادامه می‌دهد.

اما چون در helper مربوط به readJSON() فقط یک بار Decode() را صدا می‌زنیم، هر چیزی بعد از اولین مقدار JSON در request body نادیده گرفته می‌شود. یعنی می‌توانید request bodyای بفرستید که شامل چند مقدار JSON است، یا بعد از اولین مقدار JSON محتوای بی‌معنی دارد، و handlerهای API ما خطایی ایجاد نمی‌کنند. برای مثال:

# Body contains multiple JSON values
$ curl -i -d '{"title": "Moana"}{"title": "Top Gun"}' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:53:57 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:0 Runtime:0 Genres:[]}

# Body contains garbage content after the first JSON value
$ curl -i -d '{"title": "Moana"} :~()' localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 18:54:15 GMT
Content-Length: 41
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:0 Runtime:0 Genres:[]}

باز هم، این رفتار می‌تواند خیلی مفید باشد، اما برای use case ما مناسب نیست. می‌خواهیم requestهایی که به handler مربوط به createMovieHandler می‌رسند، فقط یک JSON object واحد در request body داشته باشند که شامل اطلاعات فیلمی است که باید در سیستم ما ایجاد شود.

برای اینکه مطمئن شویم هیچ مقدار JSON اضافه‌ای، یا هر محتوای دیگری، در request body وجود ندارد، باید در helper مربوط به readJSON() برای بار دوم Decode() را صدا بزنیم و بررسی کنیم که یک خطای io.EOF یا end of file برمی‌گرداند.

در نهایت، فعلا هیچ حد بالایی برای حداکثر اندازه request body وجود ندارد. یعنی createMovieHandler ما هدف خوبی برای clientهای مخربی خواهد بود که می‌خواهند حمله denial-of-service علیه API ما انجام دهند. می‌توانیم با استفاده از function مربوط به http.MaxBytesReader() برای محدود کردن حداکثر اندازه request body، این مشکل را حل کنیم.

بیایید helper مربوط به readJSON() را به‌روزرسانی کنیم تا این سه مورد را برطرف کند:

File: cmd/api/helpers.go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "strings" // New import

    "github.com/julienschmidt/httprouter"
)

...

func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    // Use http.MaxBytesReader() to limit the size of the request body to 1,048,576 
    // bytes (1MB).
    r.Body = http.MaxBytesReader(w, r.Body, 1_048_576)

    // Initialize the json.Decoder, and call the DisallowUnknownFields() method on it
    // before decoding. This means that if the JSON from the client now includes any
    // field that cannot be mapped to the target destination, the decoder will return
    // an error instead of just ignoring the field.
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    // Decode the request body to the destination.
    err := dec.Decode(dst)
    if err != nil {
        var syntaxError *json.SyntaxError
        var unmarshalTypeError *json.UnmarshalTypeError
        var invalidUnmarshalError *json.InvalidUnmarshalError
        // Add a new maxBytesError variable.
        var maxBytesError *http.MaxBytesError

        switch {
        case errors.As(err, &syntaxError):
            return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)

        case errors.Is(err, io.ErrUnexpectedEOF):
            return errors.New("body contains badly-formed JSON")

        case errors.As(err, &unmarshalTypeError):
            if unmarshalTypeError.Field != "" {
                 return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
            }
            return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)

        case errors.Is(err, io.EOF):
            return errors.New("body must not be empty")

        // If the JSON contains a field which cannot be mapped to the target destination
        // then Decode() will now return an error message in the format "json: unknown
        // field "<name>"". We check for this, extract the field name from the error,
        // and interpolate it into our custom error message. Note that there's an open
        // issue at https://github.com/golang/go/issues/29035 regarding turning this
        // into a distinct error type in the future.
        case strings.HasPrefix(err.Error(), "json: unknown field "):
            fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
            return fmt.Errorf("body contains unknown key %s", fieldName)

        // Use the errors.As() function to check whether the error has the type 
        // *http.MaxBytesError. If it does, then it means the request body exceeded our 
        // size limit of 1MB and we return a clear error message.
        case errors.As(err, &maxBytesError):
            return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit)

        case errors.As(err, &invalidUnmarshalError):
            panic(err)

        default:
            return err
        }
    }

    // Call Decode() again, using a pointer to an empty anonymous struct as the
    // destination. If the request body only contained a single JSON value this will
    // return an io.EOF error. So if we get anything else, we know that there is
    // additional data in the request body and we return our own custom error message.
    err = dec.Decode(&struct{}{})
    if !errors.Is(err, io.EOF) {
        return errors.New("body must only contain a single JSON value")
    }

    return nil
}

وقتی این تغییرات را انجام دادید، دوباره requestهای ابتدای فصل را امتحان کنیم:

$ curl -d '{"title": "Moana", "rating":"PG"}' localhost:4000/v1/movies
{
    "error": "body contains unknown key \"rating\""
}

$ curl -d '{"title": "Moana"}{"title": "Top Gun"}' localhost:4000/v1/movies
{
    "error": "body must only contain a single JSON value"
}

$ curl -d '{"title": "Moana"} :~()' localhost:4000/v1/movies
{
    "error": "body must only contain a single JSON value"
}

حالا خیلی بهتر کار می‌کنند؛ پردازش request متوقف می‌شود و client یک پیام خطای روشن دریافت می‌کند که دقیقا توضیح می‌دهد مشکل چیست.

در آخر، بیایید یک request با JSON body بسیار بزرگ بفرستیم.

برای نشان دادن این موضوع، من یک فایل JSON با اندازه 1.5MB ساخته‌ام که می‌توانید با اجرای command زیر آن را داخل directory مربوط به /tmp دانلود کنید:

$ wget -O /tmp/largefile.json https://www.alexedwards.net/static/largefile.json

اگر تلاش کنید با این فایل به عنوان request body، یک request به endpoint مربوط به POST /v1/movies بفرستید، check مربوط به http.MaxBytesReader() فعال می‌شود و باید پاسخی شبیه این بگیرید:

$ curl -d @/tmp/largefile.json localhost:4000/v1/movies
{
    "error": "body must not be larger than 1048576 bytes"
}

و با این کار، خوشحال خواهید شد بدانید که بالاخره کارمان با helper مربوط به readJSON() تمام شد.

باید اعتراف کنم کد داخل readJSON() زیباترین کد دنیا نیست… برای چیزی که در نهایت یک call یک‌خطی به Decode() است، error handling و logic زیادی اضافه کرده‌ایم. اما حالا نوشته شده و تمام است. لازم نیست دوباره به آن دست بزنید، و چیزی است که می‌توانید به‌راحتی در پروژه‌های دیگر copy-paste کنید.