diff --git a/modules/setting/api.go b/modules/setting/api.go index cdad474cb9..d18d23f282 100644 --- a/modules/setting/api.go +++ b/modules/setting/api.go @@ -19,6 +19,8 @@ var API = struct { DefaultGitTreesPerPage int DefaultMaxBlobSize int64 DefaultMaxResponseSize int64 + RateLimitEnabled bool + RateLimitPerHour int }{ EnableSwagger: true, SwaggerURL: "", @@ -27,6 +29,8 @@ var API = struct { DefaultGitTreesPerPage: 1000, DefaultMaxBlobSize: 10485760, DefaultMaxResponseSize: 104857600, + RateLimitEnabled: false, + RateLimitPerHour: 5000, } func loadAPIFrom(rootCfg ConfigProvider) { diff --git a/modules/web/middleware/rate_limit.go b/modules/web/middleware/rate_limit.go new file mode 100644 index 0000000000..29755bdae5 --- /dev/null +++ b/modules/web/middleware/rate_limit.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package middleware + +import ( + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/modules/setting" +) + +// RateLimitHeaders is the header names for rate limit information +const ( + RateLimitHeader = "X-RateLimit-Limit" + RateLimitRemainingHeader = "X-RateLimit-Remaining" + RateLimitResetHeader = "X-RateLimit-Reset" +) + +// RateLimitInfo returns a middleware that sets rate limit headers. +// This is currently informational only - actual rate limiting enforcement +// can be added in the future based on the RateLimitEnabled setting. +func RateLimitInfo() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Set informational rate limit headers + // These tell clients what to expect even if enforcement isn't active + limit := setting.API.RateLimitPerHour + + // Calculate reset time (next hour boundary) + now := time.Now() + resetTime := now.Truncate(time.Hour).Add(time.Hour) + + w.Header().Set(RateLimitHeader, strconv.Itoa(limit)) + + // When rate limiting is not enforced, remaining equals limit + // Future: implement actual tracking per user/IP + remaining := limit + if setting.API.RateLimitEnabled { + // TODO: Implement actual rate limit tracking + // For now, just show full quota when enabled + remaining = limit + } + + w.Header().Set(RateLimitRemainingHeader, strconv.Itoa(remaining)) + w.Header().Set(RateLimitResetHeader, strconv.FormatInt(resetTime.Unix(), 10)) + + next.ServeHTTP(w, req) + }) + } +} diff --git a/modules/web/middleware/request_id.go b/modules/web/middleware/request_id.go new file mode 100644 index 0000000000..36816de910 --- /dev/null +++ b/modules/web/middleware/request_id.go @@ -0,0 +1,89 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package middleware + +import ( + "context" + "net/http" + "unicode" + + "code.gitea.io/gitea/modules/setting" + + "github.com/google/uuid" +) + +type requestIDKeyType struct{} + +var requestIDKey = requestIDKeyType{} + +// RequestIDHeader is the header name for request ID +const RequestIDHeader = "X-Request-ID" + +// maxRequestIDByteLength is the maximum length for a request ID +const maxRequestIDByteLength = 40 + +// GetRequestID returns the request ID from context +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(requestIDKey).(string); ok { + return id + } + return "" +} + +// isSafeRequestID checks if the request ID contains only printable characters +func isSafeRequestID(id string) bool { + for _, r := range id { + if !unicode.IsPrint(r) { + return false + } + } + return true +} + +// parseOrGenerateRequestID extracts request ID from headers or generates a new one +func parseOrGenerateRequestID(req *http.Request) string { + // Try to get from configured headers + for _, key := range setting.Log.RequestIDHeaders { + if id := req.Header.Get(key); id != "" { + if isSafeRequestID(id) { + if len(id) > maxRequestIDByteLength { + return id[:maxRequestIDByteLength] + } + return id + } + } + } + + // Also check X-Request-ID header explicitly + if id := req.Header.Get(RequestIDHeader); id != "" { + if isSafeRequestID(id) { + if len(id) > maxRequestIDByteLength { + return id[:maxRequestIDByteLength] + } + return id + } + } + + // Generate a new request ID (short form of UUID) + id := uuid.New() + return id.String()[:8] +} + +// RequestID returns a middleware that sets X-Request-ID header +func RequestID() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + requestID := parseOrGenerateRequestID(req) + + // Store in context + ctx := context.WithValue(req.Context(), requestIDKey, requestID) + req = req.WithContext(ctx) + + // Set response header + w.Header().Set(RequestIDHeader, requestID) + + next.ServeHTTP(w, req) + }) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a447a55f26..7958b0d50b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -83,6 +83,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/routers/api/v1/activitypub" "code.gitea.io/gitea/routers/api/v1/admin" "code.gitea.io/gitea/routers/api/v1/misc" @@ -877,6 +878,8 @@ func checkDeprecatedAuthMethods(ctx *context.APIContext) { func Routes() *web.Router { m := web.NewRouter() + m.Use(middleware.RequestID()) + m.Use(middleware.RateLimitInfo()) m.Use(securityHeaders()) if setting.CORSConfig.Enabled { m.Use(cors.Handler(cors.Options{ diff --git a/routers/api/v1/repo/upload.go b/routers/api/v1/repo/upload.go index faa56cdd91..033cd7d25b 100644 --- a/routers/api/v1/repo/upload.go +++ b/routers/api/v1/repo/upload.go @@ -126,7 +126,9 @@ func UploadChunk(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{repo}/uploads/{session_id}/chunks/{chunk_number} repository repoUploadChunk // --- // summary: Upload a chunk to an upload session - // description: Uploads a single chunk of data to an existing upload session. Chunks can be uploaded in any order. + // description: | + // Uploads a single chunk of data to an existing upload session. Chunks can be uploaded in any order. + // Optionally include X-Chunk-Checksum header with SHA-256 hash for data integrity verification. // produces: // - application/json // consumes: @@ -153,6 +155,11 @@ func UploadChunk(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: X-Chunk-Checksum + // in: header + // description: SHA-256 checksum of the chunk data (hex-encoded, optional) + // type: string + // required: false // - name: body // in: body // description: chunk data @@ -205,12 +212,24 @@ func UploadChunk(ctx *context.APIContext) { // Get Content-Length for size validation contentLength := ctx.Req.ContentLength - err = attachment_service.SaveChunk(ctx, session, chunkNumber, ctx.Req.Body, contentLength) + // Get optional checksum header for integrity verification + checksum := ctx.Req.Header.Get("X-Chunk-Checksum") + + err = attachment_service.SaveChunkWithOptions(ctx, session, attachment_service.ChunkSaveOptions{ + ChunkNumber: chunkNumber, + Data: ctx.Req.Body, + Size: contentLength, + Checksum: checksum, + }) if err != nil { if repo_model.IsErrUploadSessionExpired(err) { ctx.APIError(http.StatusGone, err.Error()) return } + if attachment_service.IsErrChecksumMismatch(err) { + ctx.APIError(http.StatusBadRequest, err.Error()) + return + } ctx.APIError(http.StatusBadRequest, err.Error()) return } diff --git a/services/attachment/chunked.go b/services/attachment/chunked.go index e18e7e1e8c..843cf4dbe3 100644 --- a/services/attachment/chunked.go +++ b/services/attachment/chunked.go @@ -5,6 +5,8 @@ package attachment import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "os" @@ -59,8 +61,43 @@ func CreateChunkedUploadSession(ctx context.Context, opts ChunkedUploadOptions) return session, nil } +// ErrChecksumMismatch is returned when the chunk checksum doesn't match +type ErrChecksumMismatch struct { + Expected string + Actual string +} + +func (e ErrChecksumMismatch) Error() string { + return fmt.Sprintf("checksum mismatch: expected %s, got %s", e.Expected, e.Actual) +} + +// IsErrChecksumMismatch returns true if the error is a checksum mismatch +func IsErrChecksumMismatch(err error) bool { + _, ok := err.(ErrChecksumMismatch) + return ok +} + +// ChunkSaveOptions contains options for saving a chunk +type ChunkSaveOptions struct { + ChunkNumber int64 + Data io.Reader + Size int64 + // Checksum is the expected SHA-256 checksum of the chunk (hex-encoded) + // If empty, checksum verification is skipped + Checksum string +} + // SaveChunk saves a chunk to the upload session func SaveChunk(ctx context.Context, session *repo_model.UploadSession, chunkNumber int64, data io.Reader, size int64) error { + return SaveChunkWithOptions(ctx, session, ChunkSaveOptions{ + ChunkNumber: chunkNumber, + Data: data, + Size: size, + }) +} + +// SaveChunkWithOptions saves a chunk to the upload session with additional options +func SaveChunkWithOptions(ctx context.Context, session *repo_model.UploadSession, opts ChunkSaveOptions) error { if session.Status != repo_model.UploadSessionStatusActive { return fmt.Errorf("upload session is not active") } @@ -72,11 +109,11 @@ func SaveChunk(ctx context.Context, session *repo_model.UploadSession, chunkNumb } // Validate chunk number - if chunkNumber < 0 { - return fmt.Errorf("invalid chunk number: %d", chunkNumber) + if opts.ChunkNumber < 0 { + return fmt.Errorf("invalid chunk number: %d", opts.ChunkNumber) } - if session.ChunksExpected > 0 && chunkNumber >= session.ChunksExpected { - return fmt.Errorf("chunk number %d exceeds expected chunks %d", chunkNumber, session.ChunksExpected) + if session.ChunksExpected > 0 && opts.ChunkNumber >= session.ChunksExpected { + return fmt.Errorf("chunk number %d exceeds expected chunks %d", opts.ChunkNumber, session.ChunksExpected) } // Ensure temp directory exists @@ -85,24 +122,45 @@ func SaveChunk(ctx context.Context, session *repo_model.UploadSession, chunkNumb return fmt.Errorf("failed to create temp directory: %w", err) } - // Write chunk to temp file - chunkPath := session.GetChunkPath(chunkNumber) + // Write chunk to temp file, computing checksum if needed + chunkPath := session.GetChunkPath(opts.ChunkNumber) file, err := os.Create(chunkPath) if err != nil { return fmt.Errorf("failed to create chunk file: %w", err) } defer file.Close() - written, err := io.Copy(file, data) - if err != nil { - os.Remove(chunkPath) - return fmt.Errorf("failed to write chunk: %w", err) + var written int64 + var actualChecksum string + + if opts.Checksum != "" { + // Compute checksum while writing + hasher := sha256.New() + multiWriter := io.MultiWriter(file, hasher) + written, err = io.Copy(multiWriter, opts.Data) + if err != nil { + os.Remove(chunkPath) + return fmt.Errorf("failed to write chunk: %w", err) + } + actualChecksum = hex.EncodeToString(hasher.Sum(nil)) + } else { + written, err = io.Copy(file, opts.Data) + if err != nil { + os.Remove(chunkPath) + return fmt.Errorf("failed to write chunk: %w", err) + } } // Validate size if provided - if size > 0 && written != size { + if opts.Size > 0 && written != opts.Size { os.Remove(chunkPath) - return fmt.Errorf("chunk size mismatch: expected %d, got %d", size, written) + return fmt.Errorf("chunk size mismatch: expected %d, got %d", opts.Size, written) + } + + // Verify checksum if provided + if opts.Checksum != "" && actualChecksum != opts.Checksum { + os.Remove(chunkPath) + return ErrChecksumMismatch{Expected: opts.Checksum, Actual: actualChecksum} } // Update session @@ -113,7 +171,7 @@ func SaveChunk(ctx context.Context, session *repo_model.UploadSession, chunkNumb } log.Debug("Saved chunk %d for session %s (total: %d chunks, %d bytes)", - chunkNumber, session.UUID, session.ChunksReceived, session.BytesReceived) + opts.ChunkNumber, session.UUID, session.ChunksReceived, session.BytesReceived) return nil } diff --git a/services/context/api.go b/services/context/api.go index 591efadf37..2bb3ae6fd5 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" web_types "code.gitea.io/gitea/modules/web/types" ) @@ -57,12 +58,29 @@ func init() { // * message: the message for end users (it shouldn't be used for error type detection) // if we need to indicate some errors, we should introduce some new fields like ErrorCode or ErrorType // * url: the swagger document URL +// * request_id: the unique request ID for tracing (X-Request-ID header) +// +// RFC 7807 Problem Details fields are also included for standard compliance: +// * type: A URI reference identifying the problem type (default: "about:blank") +// * title: A short, human-readable summary of the problem type +// * status: The HTTP status code +// * detail: A human-readable explanation specific to this occurrence +// * instance: A URI reference identifying this specific occurrence (request ID) -// APIError is error format response +// APIError is error format response following RFC 7807 Problem Details // swagger:response error type APIError struct { - Message string `json:"message"` - URL string `json:"url"` + // Legacy fields (maintained for backward compatibility) + Message string `json:"message"` + URL string `json:"url"` + RequestID string `json:"request_id,omitempty"` + + // RFC 7807 Problem Details fields + Type string `json:"type,omitempty"` // URI reference identifying the problem type + Title string `json:"title,omitempty"` // Short summary of the problem type + Status int `json:"status,omitempty"` // HTTP status code + Detail string `json:"detail,omitempty"` // Explanation specific to this occurrence + Instance string `json:"instance,omitempty"` // URI reference for this specific occurrence } // APIValidationError is error format response related to input validation @@ -117,22 +135,37 @@ func (ctx *APIContext) APIErrorInternal(err error) { } func (ctx *APIContext) apiErrorInternal(skip int, err error) { - log.ErrorWithSkip(skip+1, "InternalServerError: %v", err) + requestID := middleware.GetRequestID(ctx.Req.Context()) + log.ErrorWithSkip(skip+1, "InternalServerError [%s]: %v", requestID, err) var message string + var detail string if !setting.IsProd || (ctx.Doer != nil && ctx.Doer.IsAdmin) { message = err.Error() + detail = err.Error() + } else { + message = "Internal Server Error" } ctx.JSON(http.StatusInternalServerError, APIError{ - Message: message, - URL: setting.API.SwaggerURL, + // Legacy fields + Message: message, + URL: setting.API.SwaggerURL, + RequestID: requestID, + // RFC 7807 fields + Type: "about:blank", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + Detail: detail, + Instance: requestID, }) } // APIError responds with an error message to client with given obj as the message. // If status is 500, also it prints error to log. func (ctx *APIContext) APIError(status int, obj any) { + requestID := middleware.GetRequestID(ctx.Req.Context()) + var message string if err, ok := obj.(error); ok { message = err.Error() @@ -140,17 +173,33 @@ func (ctx *APIContext) APIError(status int, obj any) { message = fmt.Sprintf("%s", obj) } + detail := message if status == http.StatusInternalServerError { - log.ErrorWithSkip(1, "APIError: %s", message) + log.ErrorWithSkip(1, "APIError [%s]: %s", requestID, message) if setting.IsProd && !(ctx.Doer != nil && ctx.Doer.IsAdmin) { - message = "" + message = "Internal Server Error" + detail = "" } } + // Get HTTP status text as the title + title := http.StatusText(status) + if title == "" { + title = "Error" + } + ctx.JSON(status, APIError{ - Message: message, - URL: setting.API.SwaggerURL, + // Legacy fields + Message: message, + URL: setting.API.SwaggerURL, + RequestID: requestID, + // RFC 7807 fields + Type: "about:blank", + Title: title, + Status: status, + Detail: detail, + Instance: requestID, }) } @@ -245,6 +294,8 @@ func APIContexter() func(http.Handler) http.Handler { // APIErrorNotFound handles 404s for APIContext // String will replace message, errors will be added to a slice func (ctx *APIContext) APIErrorNotFound(objs ...any) { + requestID := middleware.GetRequestID(ctx.Req.Context()) + var message string var errs []string for _, obj := range objs { @@ -259,10 +310,20 @@ func (ctx *APIContext) APIErrorNotFound(objs ...any) { message = obj.(string) } } + + finalMessage := util.IfZero(message, "not found") ctx.JSON(http.StatusNotFound, map[string]any{ - "message": util.IfZero(message, "not found"), // do not use locale in API - "url": setting.API.SwaggerURL, - "errors": errs, + // Legacy fields + "message": finalMessage, // do not use locale in API + "url": setting.API.SwaggerURL, + "errors": errs, + "request_id": requestID, + // RFC 7807 fields + "type": "about:blank", + "title": "Not Found", + "status": http.StatusNotFound, + "detail": finalMessage, + "instance": requestID, }) }