3 estratégias para melhorar a segurança das suas APIs

Leonardo Guilen
9 min readOct 19, 2023

--

3 estratégias para fortalecer a segurança e a confiabilidade das suas APIs, com foco em autenticação e proteção contra ataques comuns. Trago a importância de implementar uma autenticação com API Key, assinar requests e como o rate limiting pode proteger contra sobrecarga no seu backend.

Photo by FLY:D on Unsplash

Introdução

No mundo da tecnologia, APIs são componentes essenciais na comunicação entre sistemas. Mas, com tantas conexões, a segurança é um desafio crucial. Neste artigo, vamos direto ao ponto com três dicas práticas para tornar suas APIs mais seguras. Vamos falar de autenticação com API Key, assinatura de requests e rate limiting, trazendo exemplos de implementação em aplicações .NET.

1. Autenticação com API Key

A autenticação com API Key é uma forma simples e eficaz de trazer segurança em suas APIs. Ela atua como uma chave virtual que concede acesso somente a usuários autorizados, evitando o acesso não autorizado aos recursos.

Esse tipo de autenticação é especialmente útil quando você precisa controlar o acesso de aplicativos, serviços ou usuários a determinadas partes de sua API. Isso é crucial quando você quer garantir que apenas as partes confiáveis tenham permissão para utilizar seus recursos.

Exemplo de fluxos de comunicação com e sem API KEY

Existem muitas maneiras diferentes de implementar esse tipo de autenticação em APIs com .NET. Para esse exemplo, utilizaremos “Filtros” customizados e aplicaremos nos endpoints que serão protegidos.

// .NET 8
internal sealed class ApiKeyAuthenticationFilter(IConfiguration configuration) : IEndpointFilter
{
private const string ApiKeyHeaderName = "X-API-KEY";

private readonly string _apiKeySecret = configuration
.GetRequiredSection("SecretKeys")
.GetValue<string>("ApiKey")!;

public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var requestHeaders = context.HttpContext.Request.Headers;

if (!requestHeaders.TryGetValue(ApiKeyHeaderName, out var incomingApiKey))
{
return Results.Unauthorized();
}

return string.CompareOrdinal(incomingApiKey, _apiKeySecret) != 0
? Results.Unauthorized()
: await next(context);
}
}

Primeira verificação que deve ser feita é checar a existência de um header customizado X-API-KEY na requisição. Caso não exista, o endpoint não é executado e a API retorna uma status 401 (Unauthorized)

if (!requestHeaders.TryGetValue(ApiKeyHeaderName, out var incomingApiKey))
{
return Results.Unauthorized();
}

A segunda verificação é comparar o valor recebido no header com uma chave válida.

return string.CompareOrdinal(incomingApiKey, _apiKeySecret) != 0
? Results.Unauthorized()
: await next(context);

Note que a implementação desse filtro ficou bem simples. Mas, é possível melhorar ainda mais essa validação, adicionando rotação e expiração das chaves, gerar chaves únicas para cada cliente e etc.

Agora que temos o filtro implementado, podemos aplica-lo nos endpoints que devem passar por esse fluxo de autenticação.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var apiGroup = app
.MapGroup("api")
.AddEndpointFilter<ApiKeyAuthenticationFilter>();

apiGroup.MapGet("/test", () => "Hello, you are authenticated!");

app.Run();

Nesse exemplo estou usando Minimal APIs, e para aplicar o filtro utilizamos AddEndpointFilter<ApiKeyAuthenticationFilter>(). Com isso, todas as requisições aos endpoints que tiverem /api como prefixo, vão passar pelo filtro de autenticação.

Podemos testar se está tudo funcionando, enviando requisições via curl.

# Make request without set X-API-KEY header
> curl -i -X GET "https://localhost:7233/api/test"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Wed, 18 Oct 2023 13:56:00 GMT
Server: Kestrel

# Make request with X-API-KEY header setted
> curl -i "https://localhost:7233/api/test" -H "X-API-KEY: xxx"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Wed, 18 Oct 2023 13:56:35 GMT
Server: Kestrel
Transfer-Encoding: chunked

Hello, you are authenticated!

Funciona! Temos uma primeira camada de proteção nos endpoints da nossa API. Em seguida, vamos ver como adicionar uma segunda camada de proteção para melhorar mais a segurança.

2. Assinatura de requests

A assinatura de requests é essencial quando se lida com informações sensíveis ou transações críticas. Essa técnica é especialmente eficaz na prevenção de ataques de man-in-the-middle, garante que a solicitação não tenha sido adulterada no caminho entre o cliente e o servidor, impedindo ações maliciosas, como a modificação de dados durante a transferência.

Exemplo de fluxos com requisições assinadas

Em um ambiente .NET, a implementação da assinatura de requests pode ser efetuada com ferramentas de criptografia, como a geração de códigos de hash (hashing) e chaves de autenticação. A técnica geralmente envolve a criação de uma assinatura digital única para cada solicitação, que é então verificada no servidor para garantir sua integridade.

Para o exemplo, criaremos novamente um filtro que será responsável por essa validação.

internal sealed class RequestSignatureValidationFilter(IConfiguration configuration) : IEndpointFilter
{
private const string RequestContentSha256HeaderName = "X-REQUEST-CONTENT-SHA256";
private const string RequestSignatureHeaderName = "X-REQUEST-SIGNATURE";

private readonly string _signingKey = configuration
.GetRequiredSection("SecretKeys")
.GetValue<string>("SigningKey")!;

public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.HttpContext.Request;
var requestHeaders = request.Headers;

if (!requestHeaders.TryGetValue(RequestContentSha256HeaderName, out var requestContent) ||
!requestHeaders.TryGetValue(RequestSignatureHeaderName, out var requestSignature))
{
return Results.StatusCode((int)HttpStatusCode.ExpectationFailed);
}

if (!await IsValidRequestContentAsync(request, requestContent!))
{
return Results.Unauthorized();
}

if (!await IsValidRequestSignatureAsync(request, requestContent!, requestSignature!))
{
return Results.Unauthorized();
}

return await next(context);
}

private static async Task<bool> IsValidRequestContentAsync(
HttpRequest request,
string requestContent)
{
using var sha256 = SHA256.Create();
var computedHash = await sha256.ComputeHashAsync(request.Body);
var encodedComputedHash = Convert.ToBase64String(computedHash.ToArray());

return string.CompareOrdinal(requestContent, encodedComputedHash) is 0;
}

private async Task<bool> IsValidRequestSignatureAsync(
HttpRequest request,
string requestContent,
string requestSignature)
{
var date = DateTimeOffset.Parse(request.Headers.Date!).ToString("r", CultureInfo.InvariantCulture);
var stringToSign = $"{request.Method};{request.GetEncodedPathAndQuery()};{date};{request.Headers.Host};{requestContent}";

using var hmacsha256 = new HMACSHA256(Convert.FromBase64String(_signingKey));
var hashedSignature = await hmacsha256.ComputeHashAsync(new MemoryStream(Encoding.UTF8.GetBytes(stringToSign)));
var encodedhashedSignature = Convert.ToBase64String(hashedSignature);

return string.CompareOrdinal(requestSignature, encodedhashedSignature) is 0;
}
}

Muito código? Vamos destrinchar as validações presentes nesse filtro. Primeira validação é checar a presença dos headers X-REQUEST-CONTENT-SHA256 e X-REQUEST-SIGNATURE na requisição recebida, caso não possua algum, o endpoint não é executado e a API retorna um status 401 (Unauthorized)

if (!requestHeaders.TryGetValue(RequestContentSha256HeaderName, out var requestContent) ||
!requestHeaders.TryGetValue(RequestSignatureHeaderName, out var requestSignature))
{
return Results.StatusCode((int)HttpStatusCode.ExpectationFailed);
}

Próxima validação é do header X-REQUEST-CONTENT-SHA256. Esse header contêm o hash do payload da requisição em formato base64. Com isso, conseguimos fazer uma validação inicial de que o conteúdo não foi modificado no meio do caminho.

if (!await IsValidRequestContentAsync(request, requestContent!))
{
return Results.Unauthorized();
}

private static async Task<bool> IsValidRequestContentAsync(
HttpRequest request,
string requestContent)
{
using var sha256 = SHA256.Create();
var computedHash = await sha256.ComputeHashAsync(request.Body);
var encodedComputedHash = Convert.ToBase64String(computedHash.ToArray());
return string.CompareOrdinal(requestContent, encodedComputedHash) is 0;
}

Com a garantia que o conteúdo está correto, seguimos com a validação principal, que é validar a assinatura dessa requisição. O header X-REQUEST-SIGNATURE contêm a assinatura que deve ser validada.

if (!await IsValidRequestSignatureAsync(request, requestContent!, requestSignature!))
{
return Results.Unauthorized();
}

private async Task<bool> IsValidRequestSignatureAsync(
HttpRequest request,
string requestContent,
string requestSignature)
{
var date = DateTimeOffset.Parse(request.Headers.Date!).ToString("r", CultureInfo.InvariantCulture);
var stringToSign = $"{request.Method};{request.GetEncodedPathAndQuery()};{date};{request.Headers.Host};{requestContent}";

using var hmacsha256 = new HMACSHA256(Convert.FromBase64String(_signingKey));
var hashedSignature = await hmacsha256.ComputeHashAsync(new MemoryStream(Encoding.UTF8.GetBytes(stringToSign)));
var encodedhashedSignature = Convert.ToBase64String(hashedSignature);

return string.CompareOrdinal(requestSignature, encodedhashedSignature) is 0;
}

Não existe um formato certo para fazer a assinatura de suas requisições, isso pode variar de caso pra caso. Nesse exemplo, o formato é composto por base64(hmac(<http-method>;<path-and-query>;<request-timestamp>;<host>;<content-hashed>, <signing-key>)).

Com mais um filtro implementado, vamos adiciona-lo no endpoints da nossa API.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var apiGroup = app
.MapGroup("api")
.AddEndpointFilter<ApiKeyAuthenticationFilter>()
.AddEndpointFilter<RequestSignatureValidationFilter>();

apiGroup.MapGet("/test", () => "Hello, you are authenticated!");

app.Run();

Para testar tudo isso, vamos criar um aplicação de console que fará as chamadas http para nossa API.

const string ApiBaseUrl = "https://localhost/api";
const string ApiKey = "...";
const string RequestSigningKey = "...";

var httpClient = new HttpClient()
{
BaseAddress = new Uri(ApiBaseUrl),
};

var requestMessage = new HttpRequestMessage(HttpMethod.Get, $"{httpClient.BaseAddress}/test")
{
Content = new StringContent(string.Empty),
};
var requestUri = requestMessage.RequestUri;

var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var host = requestUri!.Authority;
var contentHash = ComputeContentHash(string.Empty);

var stringToSign = $"{requestMessage.Method.Method};{requestUri.PathAndQuery};{date};{host};{contentHash}";
var signature = ComputeSignature(stringToSign);

requestMessage.Headers.Add(HeaderNames.Date, date);
requestMessage.Headers.Add("X-API-KEY", ApiKey);
requestMessage.Headers.Add("X-REQUEST-CONTENT-SHA256", contentHash);
requestMessage.Headers.Add("X-REQUEST-SIGNATURE", signature);

var response = await httpClient.SendAsync(requestMessage);

Console.WriteLine("Response with status code {0}", (int)response.StatusCode);

static string ComputeContentHash(string content)
{
var hashedBytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToBase64String(hashedBytes);
}

static string ComputeSignature(string stringToSign)
{
using var hmacsha256 = new HMACSHA256(Convert.FromBase64String(RequestSigningKey));
var bytes = Encoding.UTF8.GetBytes(stringToSign);
var hashedBytes = hmacsha256.ComputeHash(bytes);
return Convert.ToBase64String(hashedBytes);
}

Ao executar, obtemos a saída da execução dessa aplicação.

Saída da execução da aplicação no console

Com essas novas implementações temos mais uma camadas de segurança em nossa API, autenticação com API Key e agora assinatura de request. Vamos seguir para o último mecanismo de segurança, rate limiting.

3. Rate Limiting

O rate limiting é crucial para evitar sobrecarregar seus servidores com um grande volume de solicitações em um curto período de tempo. Isso pode ser especialmente relevante quando suas APIs são públicas e amplamente utilizadas, ou quando você deseja garantir que um único cliente ou usuário não monopolize os recursos. Essa abordagem é eficaz na prevenção de ataques de sobrecarga (DoS e DDoS).

Exemplo de fluxo de múltiplas requisições dentro de uma janela de 10 segundos

Vamos seguir para a implementação de rate limiting em nossa API. Com .NET é muito simples e fácil de configurar isso, para o exemplo criaremos um limitador básico de janela fixada.

var builder = WebApplication.CreateBuilder(args);

// Services and dependencies
builder.Services.AddRateLimiter(_ =>
{
_.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
_.AddFixedWindowLimiter("fixed-limiter-policy", options =>
{
options.PermitLimit = 3;
options.Window = TimeSpan.FromSeconds(10);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 0;
});
});

var app = builder.Build();

// Middleware
app.UseRateLimiter();

...

O primeiro passo é injetar os serviços e dependências do rate limiting, e configurar um limitador de janela fixada, onde será permitido um limite de 3 requisições dentro de um período fixo de 10 segundos. Após isso, adicionamos o middleware na pipeline de processamento da requisição.

Com tudo configurado, só precisamos definir quais endpoints devem ter essa politica aplicada. Podemos utilizar o EnableRateLimitingAttribute, ou o método de extensão RequireRateLimiting(…).

var apiGroup = app
.MapGroup("api")
.RequireRateLimiting("fixed-limiter-policy");

apiGroup.MapGet("/test", () => "Hello, you are authenticated!");

No exemplo, utilizamos o método RequireRateLimiting, pois queremos aplicar a politica para todos os endpoints dentro de um grupo.

E com isso, temos um mecanismo de rate limiting pronto para uso. O código completo ficou assim:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ =>
{
_.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
_.AddFixedWindowLimiter("fixed-limiter-policy", options =>
{
options.PermitLimit = 3;
options.Window = TimeSpan.FromSeconds(10);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 0;
});
});

var app = builder.Build();

app.UseRateLimiter();

var apiGroup = app
.MapGroup("api")
.RequireRateLimiting("fixed-limiter-policy");

apiGroup.MapGet("/test", () => "It's working!");

app.Run();

Como teste, podemos executar o comando curl dentro de um loop para validar a politica de rate limiting aplicada.

> for ($i = 1; $i -le 5; $i++) { curl -i -X GET "http://localhost/api/test" }
# First request 1/3
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 19 Oct 2023 15:15:21 GMT
Server: Kestrel
Transfer-Encoding: chunked

It's working!

# Second request 2/3
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 19 Oct 2023 15:15:21 GMT
Server: Kestrel
Transfer-Encoding: chunked

It's working!

# Third request 3/3
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 19 Oct 2023 15:15:21 GMT
Server: Kestrel
Transfer-Encoding: chunked

It's working!

# Fourth request (Refused)
HTTP/1.1 429 Too Many Requests
Content-Length: 0
Date: Thu, 19 Oct 2023 15:15:21 GMT
Server: Kestrel

# Fifth request (Refused)
HTTP/1.1 429 Too Many Requests
Content-Length: 0
Date: Thu, 19 Oct 2023 15:15:21 GMT
Server: Kestrel

It’s working! Agora temos um terceiro mecanismo de segurança implementado em nossa API.

Conclusão

Neste artigo, exploramos três estratégias vitais para fortalecer a segurança e a confiabilidade das suas APIs: autenticação com API Key, assinatura de requests e rate limiting. Essas técnicas desempenham papéis distintos, mas igualmente importantes, na garantia da integridade dos dados, na prevenção de ataques maliciosos e na otimização do tráfego de solicitações.

Abaixo o gist com a implementação completa das técnicas abordadas nesse post:

--

--

Leonardo Guilen

Software Engineer focused on backend technologies, DevOps and Cloud. Researcher of the best coding standards, performance optimization and security enhancements