Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0006-organizacoes-e-papeis.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ A v0.3 precisa de organizações com membros e papéis, gestão de contas pelo a

Por padrão, todo método que não é `GET`/`HEAD` exige `member`; os controllers de provedores, credenciais, roteamento e notificações exigem `admin` (`@MinRole('admin')`); os endpoints de instância exigem `isInstanceAdmin`. O MCP e a CLI passam pelos mesmos controllers e serviços.
6. **Contas:** sem cadastro aberto e sem convites. O admin da instância cria contas e organizações; owner/admin de uma organização cria contas dentro dela. A conta nasce com uma senha temporária gerada pelo servidor, mostrada uma única vez, e `must_change_password=true`: até a troca, só `GET /api/v1/me`, a troca de senha e o logout respondem (os demais devolvem 403 com `code: "password_change_required"`). O admin da instância pode redefinir a senha de qualquer conta (gera outra senha temporária).
7. **URLs privadas e runners locais (#78, #102):** `THINKER_PROVIDER_PRIVATE_URLS=allow|admin-only|deny`, padrão `admin-only`. Com `admin-only`, só o admin da instância e o owner da organização cadastram provedor custom com URL privada/LAN ou conectam Ollama, LM Studio e llama.cpp; os demais recebem 400 na URL privada e 403 (`private_destination_forbidden`) no runner local. Provedores já cadastrados continuam funcionando. Com `deny`, ninguém. A proteção contra metadados de nuvem continua valendo em qualquer modo.
7. **URLs privadas e runners locais (#78, #102):** `THINKER_PROVIDER_PRIVATE_URLS=allow|admin-only|deny`, padrão `admin-only`. Com `admin-only`, só o admin da instância e o owner da organização cadastram provedor custom com URL privada/LAN ou conectam Ollama, LM Studio e llama.cpp; os demais recebem 400 na URL privada e 403 (`private_destination_forbidden`) no runner local. Provedores já cadastrados continuam funcionando. Com `deny`, ninguém. A regra vale também na conexão, não só no cadastro (emenda de 2026-11-07): cada provedor customizado e cada base URL de conta guarda o que a política permitiu a quem a salvou (`allow_private_destination`), e uma URL salva sem essa permissão não chega a endereço privado nem depois de uma troca de DNS. As linhas anteriores à emenda ficam com permissão, para continuarem funcionando. A proteção contra metadados de nuvem continua valendo em qualquer modo.

## Consequências

Expand Down
2 changes: 1 addition & 1 deletion docs/dev/guia.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ Um tier pontuado sem modelo cai no tier `default`; se o `default` também não t

**Roteamento ciente de context window** (#134, `routing/proxy/context-window-fit.ts`): antes da primeira tentativa, `ProxyService.fitChainToContext` estima os tokens do pedido (texto de `messages`/`system`/`input`/`instructions`, sem imagens e arquivos inline, mais as `tools` serializadas, a ~4 caracteres por token, somando `max_completion_tokens`/`max_tokens`/`max_output_tokens`) e tira da cadeia (principal e fallbacks) toda rota cuja janela conhecida no catálogo do agente (`DiscoveredModel.contextWindow`, lida com `snapshotRouteState`, a mesma regra do `context_length` de `/v1/models`) seja menor que a estimativa com folga: pula quando `estimativa > 0,95 × janela`. Janela desconhecida nunca pula; se nenhuma rota couber, a cadeia fica como estava. Pedidos com estimativa até 0,95 × 4096 nem consultam o catálogo. As rotas puladas saem em `X-Thinker-Context-Skipped` (IDs separados por vírgula) e o `routing_reason` não muda. Custom providers aceitam `context_window` por modelo (coluna "Context window" do formulário, guardado no JSON `models`). O piso `context_length` do `auto` em `/v1/models` continua sendo o menor da cadeia: é o tamanho que cabe em qualquer rota.

**SSRF**: URLs informadas pelo usuário (provedores customizados, URLs de assinatura) passam por `validatePublicUrl` e por `common/utils/ssrf-safe-fetch.ts` (DNS fixado, endereços de metadata de nuvem sempre recusados). LAN, loopback e `http://` são permitidos (recurso, não falha). Uma URL recusada falha a tentativa com HTTP 502 e `error.code: "provider_url_blocked"`, e o fallback continua. `HTTP_PROXY`/`HTTPS_PROXY` do processo são ignorados; o proxy de saída é o dos Servidores Proxy abaixo. Através de um proxy quem resolve o DNS do destino é o proxy: o Thinker ainda recusa nomes e IPs literais de metadata antes de enviar, e fixa o lookup do **host do proxy** (`ssrfSafeLookup`), mas o risco de rebinding do destino passa a depender do proxy escolhido pelo workspace.
**SSRF**: URLs informadas pelo usuário (provedores customizados, URLs de assinatura) passam por `validatePublicUrl` e por `common/utils/ssrf-safe-fetch.ts` (DNS fixado, endereços de metadata de nuvem sempre recusados). LAN, loopback e `http://` são permitidos (recurso, não falha), salvo quando quem salvou a URL não podia usar destino privado pela `THINKER_PROVIDER_PRIVATE_URLS`: `custom_providers.allow_private_destination` e `tenant_providers.allow_private_destination` guardam o que a política permitiu a quem salvou (linhas antigas ficam `true`), e com `false` o lookup fixado também recusa IP privado, de LAN e loopback na conexão (`ssrfSafeFetch(url, init, { allowPrivate: false })`), então trocar depois o DNS de um nome público para a rede interna não adianta. Com a política `allow`, toda URL salva pode usar destino privado. Uma URL recusada falha a tentativa com HTTP 502 e `error.code: "provider_url_blocked"`, e o fallback continua. `HTTP_PROXY`/`HTTPS_PROXY` do processo são ignorados; o proxy de saída é o dos Servidores Proxy abaixo. Através de um proxy quem resolve o DNS do destino é o proxy: o Thinker ainda recusa nomes e IPs literais de metadata antes de enviar, e fixa o lookup do **host do proxy** (`ssrfSafeLookup`), mas o risco de rebinding do destino passa a depender do proxy escolhido pelo workspace.

**URL base por conta** (`tenant_providers.base_url`, migração `AddTenantProviderBaseUrl1805400000000`): só contas `api_key` de provedores que aceitam troca de endpoint (`supportsAccountBaseUrl` em `routing/account-base-url.ts`). A URL é normalizada (`normalizeAccountBaseUrl`), validada por `checkAccountBaseUrl` (`validatePublicUrl`; destinos privados só com `canUsePrivateDestinations`) e vence a região e o endpoint padrão: `ForwardEndpointResolver` monta o override antes de região e custom (Bedrock via `resolveBedrockEndpointKey`) e a descoberta de modelos reescreve as URLs com `rebaseUrl`. Vem no cache de chaves (`CachedProviderKey.baseUrl`, `providerBaseUrl` em `resolveRouteCredentials`, Playground e fallback). API: `PATCH /api/v1/providers/connections/:id/base-url` (`{base_url: string | null}`, redescobre os modelos), `GET /api/v1/providers/default-base-url?provider=` e `accountBaseUrl` no connect; as listagens trazem `base_url`, `default_base_url` e `supports_base_url` (`baseUrlFields`). No dashboard, o campo fica no wizard de nova conta (`components/AccountBaseUrlField.tsx`) e no diálogo Manage da conexão.

Expand Down
9 changes: 9 additions & 0 deletions docs/releases/0.31.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Thinker 0.31.3

```bash
docker pull tmsoftbrasil/thinker:0.31.3
```

## Mudanças

- fix(025): the private-URL policy also holds at connect time, so DNS rebinding cannot reach the LAN
2 changes: 1 addition & 1 deletion docs/usuario/configuracao.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ O Thinker grava o corpo das requisições e respostas dos provedores (por harnes

| Variável | Padrão | Descrição |
| --- | --- | --- |
| `THINKER_PROVIDER_PRIVATE_URLS` | `admin-only` | Quem pode cadastrar provedor custom com URL privada/LAN e conectar Ollama, LM Studio e llama.cpp: `allow` (quem conecta provedores), `admin-only` (admin da instância e owner da organização) ou `deny` (ninguém). Também vale para a URL de gatilhos webhook: fora da política, ela precisa ser `https` em endereço público. Metadados de nuvem ficam bloqueados em qualquer modo, inclusive na entrega dos webhooks. Veja [Organizações](organizacoes.md). |
| `THINKER_PROVIDER_PRIVATE_URLS` | `admin-only` | Quem pode cadastrar provedor custom com URL privada/LAN e conectar Ollama, LM Studio e llama.cpp: `allow` (quem conecta provedores), `admin-only` (admin da instância e owner da organização) ou `deny` (ninguém). Também vale para a URL de gatilhos webhook: fora da política, ela precisa ser `https` em endereço público. Metadados de nuvem ficam bloqueados em qualquer modo, inclusive na entrega dos webhooks. A regra vale em toda requisição, não só no cadastro: uma URL salva por quem não podia usar destino privado nunca chega à rede interna, mesmo que o DNS dela mude depois. Veja [Organizações](organizacoes.md). |
| `OLLAMA_HOST` | `http://host.docker.internal:11434` (compose) | Pré-preenche esquema, host e porta de um **novo** servidor Ollama (aceita `host`, `host:porta` ou URL; `0.0.0.0` vira o host do container). Na atualização para a versão com servidores locais, as conexões Ollama existentes viram um servidor neste endereço. |
| `THINKER_LMSTUDIO_BASE_URL` | vazio | Pré-preenche um novo servidor LM Studio (URL `http(s)` absoluta, com ou sem `/v1`). Valor inválido é ignorado. |
| `THINKER_LLAMACPP_BASE_URL` | vazio | Pré-preenche um novo servidor llama.cpp, com as mesmas regras (padrão sem a variável: porta 9931). |
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions packages/backend/src/common/outbound/outbound-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,12 @@ export function classifyProxyError(error: unknown): ProxyErrorClass | null {
export interface OutboundFetchOptions {
/** User-supplied destination: pin direct connections to validated addresses. */
pinDestination?: boolean;
/**
* With `pinDestination`: false also refuses private, LAN and loopback
* addresses on direct connections (a destination the private-URL policy did
* not allow when it was registered). Default true.
*/
allowPrivateDestination?: boolean;
}

type FetchInit = RequestInit;
Expand Down Expand Up @@ -614,8 +620,15 @@ function countBody(response: Response, done: (bytes: number) => void): Response
});
}

function directFetch(url: string, init: FetchInit, pin: boolean): Promise<Response> {
return pin ? pinnedDirectFetch(url, init) : fetch(url, init);
function directFetch(
url: string,
init: FetchInit,
pin: boolean,
allowPrivate = true,
): Promise<Response> {
return pin
? pinnedDirectFetch(url, init, allowPrivate ? {} : { allowPrivate: false })
: fetch(url, init);
}

/** One fetch through `proxy`. Throws the raw undici error on failure. */
Expand Down Expand Up @@ -648,6 +661,7 @@ async function runChain(
url: string,
init: FetchInit,
pin: boolean,
allowPrivate = true,
): Promise<Response> {
const target = new URL(url);
const destination = destinationOf(target);
Expand Down Expand Up @@ -713,7 +727,7 @@ async function runChain(
let tunnelMs: number | null = null;
try {
if (item.kind === 'direct') {
response = await directFetch(url, init, pin);
response = await directFetch(url, init, pin, allowPrivate);
} else {
({ response, tunnelMs } = await fetchViaProxy(item.proxy, url, init));
}
Expand Down Expand Up @@ -882,18 +896,19 @@ export async function outboundFetch(
): Promise<Response> {
const href = typeof url === 'string' ? url : url.href;
const pin = opts.pinDestination === true;
const allowPrivate = opts.allowPrivateDestination !== false;
const scope = currentOutboundScope();
const chain = scope?.chain;
if (scope && chain && chain.length > 0) {
return runChain(scope, chain, href, init, pin);
return runChain(scope, chain, href, init, pin, allowPrivate);
}
const global = getGlobalProxyConfig();
const hostname = new URL(href).hostname;
if (global && !global.noProxy.matches(hostname)) {
return viaGlobal(global, scope, href, init);
}
const started = performance.now();
const response = await directFetch(href, init, pin);
const response = await directFetch(href, init, pin, allowPrivate);
scope?.hops.push({
kind: 'direct',
proxy_id: null,
Expand Down
10 changes: 10 additions & 0 deletions packages/backend/src/common/utils/private-url-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertCanUseLocalRunners,
allowsPrivateDestination,
canUsePrivateDestinations,
privateUrlPolicy,
} from './private-url-policy';
Expand All @@ -24,4 +25,13 @@ describe('private URL policy (#78, #102)', () => {
expect(canUsePrivateDestinations({ role: 'admin' }, allow)).toBe(true);
expect(canUsePrivateDestinations({ role: 'owner', isInstanceAdmin: true }, deny)).toBe(false);
});

it('a stored destination keeps what the policy allowed its registrant; allow lets all through', () => {
expect(allowsPrivateDestination(false, {})).toBe(false);
expect(allowsPrivateDestination(true, {})).toBe(true);
// Rows saved before the column existed keep working.
expect(allowsPrivateDestination(undefined, {})).toBe(true);
expect(allowsPrivateDestination(false, { THINKER_PROVIDER_PRIVATE_URLS: 'allow' })).toBe(true);
expect(allowsPrivateDestination(false, { THINKER_PROVIDER_PRIVATE_URLS: 'deny' })).toBe(false);
});
});
14 changes: 14 additions & 0 deletions packages/backend/src/common/utils/private-url-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ export function canUsePrivateDestinations(
return ctx.isInstanceAdmin === true || ctx.role === 'owner';
}

/**
* Whether a stored destination may reach private and LAN addresses at connect
* time. `stored` is what the policy said for whoever registered it
* (`allow_private_destination`); `allow` lets every destination through.
* Rows registered before the column existed default to true, so destinations
* already in use keep working (ADR 0006 item 7).
*/
export function allowsPrivateDestination(
stored: boolean | null | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return privateUrlPolicy(env) === 'allow' || stored !== false;
}

export function assertCanUseLocalRunners(
ctx: Pick<TenantContext, 'role' | 'isInstanceAdmin'>,
env: NodeJS.ProcessEnv = process.env,
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/common/utils/ssrf-safe-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ describe('assertConnectableHost', () => {
);
});

describe('destinations registered without private access (ADR 0006 item 7)', () => {
beforeEach(() => lookup.mockReset());

it('refuses a public name rebound onto the LAN or loopback at connect time', async () => {
for (const address of ['10.0.0.5', '127.0.0.1', '192.168.1.20', 'fd12::5']) {
lookup.mockResolvedValueOnce([{ address, family: address.includes(':') ? 6 : 4 }]);
await expect(
resolveConnectableAddresses('llm.example.com', { allowPrivate: false }),
).rejects.toBeInstanceOf(SsrfBlockedError);
}
});

it('still connects to public addresses, and refuses private literals before DNS', async () => {
lookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
await expect(
resolveConnectableAddresses('llm.example.com', { allowPrivate: false }),
).resolves.toEqual([{ address: '93.184.216.34', family: 4 }]);
expect(() => assertConnectableHost('10.0.0.5', { allowPrivate: false })).toThrow(
SsrfBlockedError,
);
expect(() => assertConnectableHost('10.0.0.5')).not.toThrow();
});

it('ssrfSafeFetch refuses the rebound connection without opening it', async () => {
lookup.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
await expect(
ssrfSafeFetch('http://rebind.example:9/', {}, { allowPrivate: false }),
).rejects.toThrow();
});
});

describe('resolveConnectableAddresses', () => {
beforeEach(() => lookup.mockReset());

Expand Down
Loading
Loading