Harden Homarr provider limits

This commit is contained in:
ajnart committed 2026-08-11 22:53:49 +02:00
1 parent 47c349bcd4
commit 0774b02878
14 files changed
+463 -459

No files matched your search

+88 -33
View File
@@ -23,18 +23,27 @@ import (
const (
homarrProviderModelID = "homarr/deepseek-v4-flash-latest"
openRouterModelID = "~deepseek/deepseek-v4-flash-latest"
maxChatInputTokens = 256 * 1024
maxChatOutputTokens = 32 * 1024
defaultDailyLimit = 50
maxProviderRequestUnits = 1000
maxChatRequestBytes = 8 << 20
maxChatResponseBytes = 8 << 20
maxUpstreamErrorBytes = 1 << 20
openRouterDefaultURL = "https://openrouter.ai/api/v1"
providerRequestTimeout = 5 * time.Minute
// Do not trust a client-side token estimate. Applying the same numeric ceiling to
// the complete serialized payload is deliberately stricter than a tokenizer-based
// limit and also bounds attachments and tool schemas.
maxChatRequestBytes = maxChatInputTokens
maxChatResponseBytes = 8 << 20
maxUpstreamErrorBytes = 1 << 20
maxWebSearchResults = 5
maxWebSearchUses = 3
openRouterDefaultURL = "https://openrouter.ai/api/v1"
providerRequestTimeout = 5 * time.Minute
)
var errProviderResponseTooLarge = errors.New("provider response is too large")
var clientControlledRoutingFields = []string{"models", "provider", "route", "plugins", "transforms"}
var clientControlledRoutingFields = []string{
"models", "provider", "route", "plugins", "transforms", "extra_body", "extra_headers",
}
type homarrProvider struct {
apiKey string
@@ -97,8 +106,9 @@ func newHomarrProviderFromEnv() (*homarrProvider, error) {
baseURL = openRouterDefaultURL
}
parsedURL, err := url.Parse(baseURL)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") {
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL")
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") ||
parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL without credentials, a query, or a fragment")
}
dailyLimit := defaultDailyLimit
@@ -136,7 +146,7 @@ func (provider *homarrProvider) models(event *core.RequestEvent) error {
"name": "DeepSeek V4 Flash Latest",
"description": "Fast agentic model provided by the community-funded Homarr provider.",
"owned_by": "homarr",
"context_length": 1_048_576,
"context_length": maxChatInputTokens,
"supported_parameters": []string{
"tools", "tool_choice", "reasoning", "include_reasoning", "structured_outputs",
},
@@ -183,7 +193,9 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
if err := json.Unmarshal(body, &payload); err != nil {
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
}
sanitizeProviderPayload(payload)
if err := sanitizeProviderPayload(payload); err != nil {
return event.JSON(http.StatusBadRequest, openAIError(err.Error()))
}
upstreamBody, err := json.Marshal(payload)
if err != nil {
return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared."))
@@ -208,7 +220,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
bytes.NewReader(upstreamBody),
)
if err != nil {
provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits)
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared."))
}
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
@@ -220,7 +232,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
if err != nil {
provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits)
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
}
defer func() { _ = upstreamResponse.Body.Close() }()
@@ -239,17 +251,15 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
contentType = "application/json"
}
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
errorBody, _ := io.ReadAll(io.LimitReader(upstreamResponse.Body, maxUpstreamErrorBytes))
provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits)
event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(min(snapshot.Limit, snapshot.Remaining+requestUnits)))
return event.Blob(upstreamResponse.StatusCode, contentType, errorBody)
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(upstreamResponse.StatusCode, openAIError("The model endpoint rejected the request."))
}
if requestBody.Stream {
capture := newSSEUsageCapture()
event.Response.Header().Set("Content-Type", contentType)
event.Response.WriteHeader(upstreamResponse.StatusCode)
_, copyErr := io.Copy(flushingWriter{event.Response}, io.TeeReader(upstreamResponse.Body, capture))
_, copyErr := copyBounded(flushingWriter{event.Response}, io.TeeReader(upstreamResponse.Body, capture), maxChatResponseBytes)
provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(copyErr), capture.Usage(), startedAt)
return nil
}
@@ -264,12 +274,72 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
}
func sanitizeProviderPayload(payload map[string]any) {
func sanitizeProviderPayload(payload map[string]any) error {
for _, field := range clientControlledRoutingFields {
delete(payload, field)
}
delete(payload, "max_completion_tokens")
delete(payload, "metadata")
delete(payload, "user")
if reasoning, ok := payload["reasoning"].(map[string]any); ok {
delete(reasoning, "max_tokens")
}
if rawTools, exists := payload["tools"]; exists {
tools, ok := rawTools.([]any)
if !ok {
return errors.New("The request tools are not valid.")
}
sanitizedTools := make([]any, 0, len(tools))
webSearchAdded := false
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
return errors.New("The request contains an unsupported tool.")
}
switch tool["type"] {
case "function":
sanitizedTools = append(sanitizedTools, tool)
case "openrouter:web_search":
if !webSearchAdded {
sanitizedTools = append(sanitizedTools, map[string]any{
"type": "openrouter:web_search",
"parameters": map[string]any{
"max_results": maxWebSearchResults,
"max_uses": maxWebSearchUses,
},
})
webSearchAdded = true
}
default:
return errors.New("The request contains an unsupported tool.")
}
}
payload["tools"] = sanitizedTools
}
payload["model"] = openRouterModelID
payload["max_tokens"] = maxChatOutputTokens
payload["n"] = 1
payload["parallel_tool_calls"] = false
payload["usage"] = map[string]any{"include": true}
return nil
}
func copyBounded(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
written, err := io.Copy(writer, io.LimitReader(reader, limit))
if err != nil {
return written, err
}
var extra [1]byte
read, err := reader.Read(extra[:])
if read > 0 {
return written, errProviderResponseTooLarge
}
if err != nil && !errors.Is(err, io.EOF) {
return written, err
}
return written, nil
}
func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) {
@@ -393,18 +463,6 @@ func (provider *homarrProvider) finishActivity(
status string,
usage requestUsage,
startedAt time.Time,
) {
provider.finishActivityWithRefund(app, privateRequest, publicActivity, status, usage, startedAt, 0)
}
func (provider *homarrProvider) finishActivityWithRefund(
app core.App,
privateRequest *core.Record,
publicActivity *core.Record,
status string,
usage requestUsage,
startedAt time.Time,
refundUnits int,
) {
durationMs := max(0, int(provider.now().UTC().Sub(startedAt).Milliseconds()))
err := app.RunInTransaction(func(txApp core.App) error {
@@ -430,9 +488,6 @@ func (provider *homarrProvider) finishActivityWithRefund(
if !requestBelongsToQuotaDay(quota.GetString("day"), startedAt) {
return nil
}
if refundUnits > 0 {
quota.Set("used", max(0, quota.GetInt("used")-refundUnits))
}
quota.Set("inputTokens", quota.GetInt("inputTokens")+usage.InputTokens)
quota.Set("outputTokens", quota.GetInt("outputTokens")+usage.OutputTokens)
quota.Set("totalTokens", quota.GetInt("totalTokens")+usage.TotalTokens)
+85 -8
View File
@@ -10,15 +10,34 @@ import (
func TestSanitizeProviderPayload(t *testing.T) {
payload := map[string]any{
"model": homarrProviderModelID,
"models": []string{"attacker/model"},
"provider": map[string]any{"order": []string{"attacker"}},
"route": "fallback",
"plugins": []string{"web"},
"transforms": []string{"middle-out"},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
"model": homarrProviderModelID,
"models": []string{"attacker/model"},
"provider": map[string]any{"order": []string{"attacker"}},
"route": "fallback",
"plugins": []string{"web"},
"transforms": []string{"middle-out"},
"max_tokens": 1_000_000,
"max_completion_tokens": 1_000_000,
"n": 100,
"parallel_tool_calls": true,
"extra_body": map[string]any{"provider": map[string]any{"order": []string{"attacker"}}},
"extra_headers": map[string]any{"Authorization": "Bearer attacker"},
"metadata": map[string]any{"user": "private-user-id"},
"reasoning": map[string]any{"effort": "high", "max_tokens": 1_000_000},
"user": "private-user-id",
"tools": []any{
map[string]any{"type": "function", "function": map[string]any{"name": "board_list"}},
map[string]any{
"type": "openrouter:web_search",
"parameters": map[string]any{"max_results": 1000, "max_uses": 1000},
},
map[string]any{"type": "openrouter:web_search"},
},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
if err := sanitizeProviderPayload(payload); err != nil {
t.Fatal(err)
}
sanitizeProviderPayload(payload)
for _, field := range clientControlledRoutingFields {
if _, exists := payload[field]; exists {
t.Fatalf("client-controlled routing field %q was forwarded", field)
@@ -27,12 +46,44 @@ func TestSanitizeProviderPayload(t *testing.T) {
if payload["model"] != openRouterModelID {
t.Fatalf("unexpected upstream model: %v", payload["model"])
}
if payload["max_tokens"] != maxChatOutputTokens || payload["n"] != 1 || payload["parallel_tool_calls"] != false {
t.Fatalf("unsafe generation controls were forwarded: %#v", payload)
}
if _, exists := payload["max_completion_tokens"]; exists {
t.Fatal("client max_completion_tokens was forwarded")
}
if _, exists := payload["user"]; exists {
t.Fatal("client user identifier was forwarded")
}
if _, exists := payload["metadata"]; exists {
t.Fatal("client metadata was forwarded")
}
reasoning := payload["reasoning"].(map[string]any)
if _, exists := reasoning["max_tokens"]; exists || reasoning["effort"] != "high" {
t.Fatalf("reasoning budget was not bounded safely: %#v", reasoning)
}
tools := payload["tools"].([]any)
if len(tools) != 2 {
t.Fatalf("unexpected sanitized tool count: %d", len(tools))
}
webSearch := tools[1].(map[string]any)
parameters := webSearch["parameters"].(map[string]any)
if parameters["max_results"] != maxWebSearchResults || parameters["max_uses"] != maxWebSearchUses {
t.Fatalf("web search limits were not enforced: %#v", parameters)
}
usage, err := json.Marshal(payload["usage"])
if err != nil || string(usage) != `{"include":true}` {
t.Fatalf("unexpected usage options: %s, %v", usage, err)
}
}
func TestSanitizeProviderPayloadRejectsUnsupportedServerTools(t *testing.T) {
payload := map[string]any{"tools": []any{map[string]any{"type": "openrouter:computer"}}}
if err := sanitizeProviderPayload(payload); err == nil {
t.Fatal("expected unsupported server tool to be rejected")
}
}
func TestCountRequestUnits(t *testing.T) {
tests := []struct {
name string
@@ -111,6 +162,18 @@ func TestProviderEnvironment(t *testing.T) {
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatal("expected invalid daily limit to fail")
}
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "50")
for _, unsafeURL := range []string{
"https://user:password@router.example/v1",
"https://router.example/v1?key=secret",
"https://router.example/v1#fragment",
} {
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", unsafeURL)
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatalf("expected unsafe upstream URL %q to fail", unsafeURL)
}
}
}
func TestReadBoundedBody(t *testing.T) {
@@ -123,6 +186,20 @@ func TestReadBoundedBody(t *testing.T) {
}
}
func TestCopyBounded(t *testing.T) {
var target strings.Builder
written, err := copyBounded(&target, strings.NewReader("1234"), 4)
if err != nil || written != 4 || target.String() != "1234" {
t.Fatalf("unexpected bounded copy: %q, %d, %v", target.String(), written, err)
}
target.Reset()
written, err = copyBounded(&target, strings.NewReader("12345"), 4)
if err != errProviderResponseTooLarge || written != 4 || target.String() != "1234" {
t.Fatalf("expected bounded copy failure, got %q, %d, %v", target.String(), written, err)
}
}
func TestRequestBelongsToQuotaDay(t *testing.T) {
startedAt := time.Date(2026, 8, 12, 1, 30, 0, 0, time.FixedZone("CEST", 2*60*60))
if !requestBelongsToQuotaDay("2026-08-11", startedAt) {
+23
View File
@@ -28,11 +28,34 @@ const server = createServer(async (request, response) => {
json(response, 400, { error: { message: "Unexpected upstream model" } });
return;
}
if (body.max_tokens !== 32_768 || body.n !== 1 || body.parallel_tool_calls !== false) {
json(response, 400, { error: { message: "Unsafe generation controls reached the upstream" } });
return;
}
if (
"max_completion_tokens" in body ||
"user" in body ||
"metadata" in body ||
"extra_body" in body ||
"extra_headers" in body ||
"max_tokens" in (body.reasoning ?? {})
) {
json(response, 400, { error: { message: "Private or unbounded request fields reached the upstream" } });
return;
}
const forbiddenRoutingField = ["models", "provider", "route", "plugins", "transforms"].find((field) => field in body);
if (forbiddenRoutingField) {
json(response, 400, { error: { message: `Client routing field was forwarded: ${forbiddenRoutingField}` } });
return;
}
const webSearchTools = body.tools?.filter((tool) => tool.type === "openrouter:web_search") ?? [];
if (
webSearchTools.length > 1 ||
webSearchTools.some((tool) => tool.parameters?.max_results !== 5 || tool.parameters?.max_uses !== 3)
) {
json(response, 400, { error: { message: "Unbounded web search controls reached the upstream" } });
return;
}
if (body.messages?.some((message) => message.content === "upstream failure")) {
json(response, 503, { error: { message: "Simulated upstream failure" } });
return;
+53 -6
View File
@@ -136,11 +136,23 @@ const modelList = await request("/api/ai/v1/models");
if (
modelList.object !== "list" ||
modelList.data.length !== 1 ||
modelList.data[0].id !== "homarr/deepseek-v4-flash-latest"
modelList.data[0].id !== "homarr/deepseek-v4-flash-latest" ||
modelList.data[0].context_length !== 256 * 1024
) {
throw new Error("The Homarr provider must advertise exactly one model");
}
await expectStatus("/api/ai/usage", {}, 401);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [{ role: "user", content: "unauthenticated" }],
}),
},
401,
);
const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers });
const initialReset = new Date(initialUsage.resetsAt);
@@ -173,6 +185,19 @@ await expectStatus(
},
400,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [{ role: "user", content: "x".repeat(256 * 1024) }],
}),
},
413,
);
const usageAfterRejectedRequest = await request("/api/ai/usage", { headers: authorSession.headers });
if (usageAfterRejectedRequest.used !== 0) {
throw new Error("A rejected oversized request-unit batch consumed allowance");
@@ -266,6 +291,20 @@ const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
route: "fallback",
plugins: ["web"],
transforms: ["middle-out"],
max_tokens: 1_000_000,
max_completion_tokens: 1_000_000,
n: 100,
parallel_tool_calls: true,
extra_body: { provider: { order: ["attacker"] } },
extra_headers: { Authorization: "Bearer attacker" },
metadata: { user: "must-not-reach-openrouter" },
reasoning: { effort: "high", max_tokens: 1_000_000 },
user: "must-not-reach-openrouter",
tools: [
{ type: "function", function: { name: "board_list" } },
{ type: "openrouter:web_search", parameters: { max_results: 1000, max_uses: 1000 } },
{ type: "openrouter:web_search" },
],
}),
});
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") {
@@ -286,12 +325,20 @@ const upstreamFailure = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
stream: false,
}),
});
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "46") {
throw new Error("Failed upstream requests must refund their Homarr provider allowance");
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "45") {
throw new Error("Every request forwarded upstream must consume Homarr provider allowance");
}
const upstreamFailureBody = await upstreamFailure.text();
if (upstreamFailureBody.includes("Simulated upstream failure")) {
throw new Error("Upstream error details must not be exposed to clients");
}
const afterFailureUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterFailureUsage.used !== 4 || afterFailureUsage.remaining !== 46) {
throw new Error(`Failed upstream request was charged: ${JSON.stringify(afterFailureUsage)}`);
if (afterFailureUsage.used !== 5 || afterFailureUsage.remaining !== 45) {
throw new Error(`Forwarded upstream failure escaped quota accounting: ${JSON.stringify(afterFailureUsage)}`);
}
const visitorUsage = await request("/api/ai/usage", { headers: visitorSession.headers });
if (visitorUsage.used !== 0 || visitorUsage.remaining !== 50) {
throw new Error(`Provider allowance leaked across users: ${JSON.stringify(visitorUsage)}`);
}
const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10");
@@ -343,7 +390,7 @@ if (retainedRequest.id !== requestToRetain.id || retainedRequest.publicActivity)
}
const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders });
const authorQuota = quotas.items.find((item) => item.user === author.id);
if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 4 || authorQuota.totalTokens !== 38) {
if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 5 || authorQuota.totalTokens !== 38) {
throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`);
}