From 417581bfc731de11553eb832d8d62a1d02e5ad58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:03:43 +0000 Subject: [PATCH 1/4] fix(anthropic): strip trailing slashes from the Claude API URL in linear time Background: CodeQL flagged /\/+$/ on the Claude API URL as a polynomial regular expression: on a value made of many slashes it backtracks quadratically. The same pattern was added for the Claude OCR base URL. Changes: - createAnthropicClient() and MistralOcrService.apiBase (anthropic branch) use the existing stripTrailingSlashes() helper from serviceUtils, which scans once from the end. Testing: - node scripts/run-tests.js --all: 117 passed, 0 failed - ESLint and Prettier clean on both files Impact: Same result for every URL; no quadratic cost on crafted input. Upstream Status: Not applicable (fork-only provider). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4 --- services/anthropicSupport.js | 9 +++++---- services/mistralOcrService.js | 14 ++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/services/anthropicSupport.js b/services/anthropicSupport.js index 5122d8d9..7db4e4ef 100644 --- a/services/anthropicSupport.js +++ b/services/anthropicSupport.js @@ -19,7 +19,10 @@ */ const Anthropic = require('@anthropic-ai/sdk'); -const { assertCompletionNotTruncated } = require('./serviceUtils'); +const { + assertCompletionNotTruncated, + stripTrailingSlashes, +} = require('./serviceUtils'); /** The model a fresh installation starts with. */ const ANTHROPIC_DEFAULT_MODEL = 'claude-haiku-5-5'; @@ -323,9 +326,7 @@ function anthropicMaxTokens(requested, configured) { */ function createAnthropicClient({ apiKey, apiUrl = '', timeoutMs } = {}) { const clientOptions = { apiKey: String(apiKey || '') }; - const baseURL = String(apiUrl || '') - .trim() - .replace(/\/+$/, ''); + const baseURL = stripTrailingSlashes(String(apiUrl || '').trim()); if (baseURL) clientOptions.baseURL = baseURL; if (typeof timeoutMs === 'number' && timeoutMs > 0) { clientOptions.timeout = timeoutMs; diff --git a/services/mistralOcrService.js b/services/mistralOcrService.js index 0a582c2c..401c79e4 100644 --- a/services/mistralOcrService.js +++ b/services/mistralOcrService.js @@ -17,7 +17,11 @@ const popplerService = require('./popplerService'); const documentModel = require('../models/document'); const AIServiceFactory = require('./aiServiceFactory'); const documentProcessingService = require('./documentProcessingService'); -const { isTimeoutError, buildTimeoutErrorMessage } = require('./serviceUtils'); +const { + isTimeoutError, + buildTimeoutErrorMessage, + stripTrailingSlashes, +} = require('./serviceUtils'); const { ANTHROPIC_DEFAULT_MODEL, anthropicThinkingParams, @@ -195,9 +199,11 @@ class MistralOcrService { // OCR_API_URL only when the operator set one (a proxy used for OCR // alone); otherwise the AI provider's ANTHROPIC_API_URL. Empty means // api.anthropic.com. - return String(config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || '') - .trim() - .replace(/\/+$/, ''); + return stripTrailingSlashes( + String( + config.mistralOcr?.apiUrl || config.anthropic?.apiUrl || '' + ).trim() + ); } if (this.provider === 'ollama') { From c4a02a78189a69c0deffe3136e35ab760627399f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:08:25 +0000 Subject: [PATCH 2/4] fix(anthropic): only send the stored Claude key to a configured host Background: The settings AI and OCR test/model endpoints treat an empty key field as "use the stored key". For Claude they did so whatever URL the request named, so a signed-in caller could send apiUrl=https://some.host with an empty key and receive ANTHROPIC_API_KEY (or the Claude OCR_API_KEY) in the x-api-key header. The same file already refuses this for Quickstart (resolveSettingsQuickstartApiKey); Claude did not follow that rule. Changes: - anthropicSupport: isConfiguredAnthropicHost() accepts an empty URL, ANTHROPIC_API_URL (empty = api.anthropic.com) and, while Claude is the OCR provider, OCR_API_URL. - routes/setup.js: resolveSettingsAiToken() and resolveOcrApiKeyForProvider() withhold the stored Claude key from any other host. Typed keys are unchanged. - setupService.resolveAnthropicOcrConnection(): the ANTHROPIC_API_KEY fallback follows the same rule, which also covers the setup routes. - Tests: the stored-key case now configures its host; a new case checks that AI models, OCR models and the OCR connection get no stored key for an unconfigured host (fails without the fix). Testing: - node scripts/run-tests.js --all: 117 passed, 0 failed - ESLint and Prettier clean on changed files, no OpenAPI drift Impact: No change for the settings page, which sends the configured URL. Testing a new proxy URL now needs the key typed in, as with Quickstart. Upstream Status: Not applicable (fork-only provider). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4 --- routes/setup.js | 47 ++++++++++++++++++++++++------- services/anthropicSupport.js | 44 +++++++++++++++++++++++++++++ services/setupService.js | 25 ++++++++-------- tests/test-anthropic-settings.js | 44 +++++++++++++++++++++++++++++ tests/test-ocr-model-fetch-key.js | 2 +- 5 files changed, 140 insertions(+), 22 deletions(-) diff --git a/routes/setup.js b/routes/setup.js index 0226dacf..02d2fe0b 100644 --- a/routes/setup.js +++ b/routes/setup.js @@ -56,6 +56,7 @@ const { ANTHROPIC_SUGGESTED_MODELS, ANTHROPIC_EFFORT_LEVELS, ANTHROPIC_DEFAULT_MAX_TOKENS, + isConfiguredAnthropicHost, } = require('../services/anthropicSupport'); const config = require('../config/config.js'); require('dotenv').config({ path: '../data/.env' }); @@ -3787,9 +3788,17 @@ function resolveStoredAiToken(aiProvider) { return ''; } -function resolveSettingsAiToken(aiProvider, token) { +function resolveSettingsAiToken(aiProvider, token, apiUrl) { const normalizedToken = String(token || '').trim(); - return normalizedToken || resolveStoredAiToken(aiProvider); + if (normalizedToken) return normalizedToken; + // The saved Claude key only goes to a Claude endpoint already configured. + const provider = String(aiProvider || '') + .trim() + .toLowerCase(); + if (provider === 'anthropic' && !isConfiguredAnthropicHost(apiUrl)) { + return ''; + } + return resolveStoredAiToken(aiProvider); } /* Accepts what the row menu sends (one id) and what the bulk menu sends (a @@ -3878,8 +3887,10 @@ function resolveOcrApiKey(apiKey) { /* resolveOcrApiKey() for a request that names its OCR provider. Claude OCR must never receive a stored Mistral key: MISTRAL_API_KEY never belongs to it, and OCR_API_KEY only when Claude is the OCR provider it was saved for. - An empty answer is fine — setupService falls back to the Anthropic AI key. */ -function resolveOcrApiKeyForProvider(provider, apiKey) { + An empty answer is fine — setupService falls back to the Anthropic AI key. + `apiUrl` is where the request goes; the stored key is withheld from any + host that is not already configured for Claude. */ +function resolveOcrApiKeyForProvider(provider, apiKey, apiUrl = '') { const normalizedProvider = String(provider || '') .trim() .toLowerCase(); @@ -3895,7 +3906,9 @@ function resolveOcrApiKeyForProvider(provider, apiKey) { const storedProvider = String(process.env.OCR_PROVIDER || '') .trim() .toLowerCase(); - return storedProvider === 'anthropic' ? process.env.OCR_API_KEY || '' : ''; + return storedProvider === 'anthropic' && isConfiguredAnthropicHost(apiUrl) + ? process.env.OCR_API_KEY || '' + : ''; } async function validatePaperlessConnectionForSetup( @@ -5088,7 +5101,11 @@ router.post('/api/setup/ocr/models', express.json(), async (req, res) => { apiUrl: req.body?.apiUrl, // The wizard used to take the field at face value, so an operator whose // key already sat in the environment was told to supply one. - apiKey: resolveOcrApiKeyForProvider(req.body?.provider, req.body?.apiKey), + apiKey: resolveOcrApiKeyForProvider( + req.body?.provider, + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl + ), anthropicApiKey: req.body?.anthropicApiKey, anthropicApiUrl: req.body?.anthropicApiUrl, anthropicModel: req.body?.anthropicModel, @@ -5214,7 +5231,11 @@ router.post( const validation = await validateAiConnectionForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), model: req.body?.model, azureApiVersion: req.body?.azureApiVersion, setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, @@ -5297,7 +5318,8 @@ router.post( apiUrl: req.body?.apiUrl, apiKey: resolveOcrApiKeyForProvider( req.body?.provider, - req.body?.apiKey + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl ), model: req.body?.model, setupOcrValidationTimeoutMs: @@ -5330,7 +5352,11 @@ router.post( const result = await discoverAiModelsForSetup({ aiProvider: req.body?.aiProvider, apiUrl: req.body?.apiUrl, - token: resolveSettingsAiToken(req.body?.aiProvider, req.body?.token), + token: resolveSettingsAiToken( + req.body?.aiProvider, + req.body?.token, + req.body?.apiUrl + ), setupValidationTimeoutMs: req.body?.setupValidationTimeoutMs, }); @@ -5451,7 +5477,8 @@ router.post( apiUrl: req.body?.apiUrl, apiKey: resolveOcrApiKeyForProvider( req.body?.provider, - req.body?.apiKey + req.body?.apiKey, + req.body?.apiUrl || req.body?.anthropicApiUrl ), anthropicApiKey: req.body?.anthropicApiKey, anthropicApiUrl: req.body?.anthropicApiUrl, diff --git a/services/anthropicSupport.js b/services/anthropicSupport.js index 7db4e4ef..d4f1ddc1 100644 --- a/services/anthropicSupport.js +++ b/services/anthropicSupport.js @@ -334,6 +334,49 @@ function createAnthropicClient({ apiKey, apiUrl = '', timeoutMs } = {}) { return new Anthropic(clientOptions); } +/** Where a client goes when no proxy URL is set. */ +const ANTHROPIC_DEFAULT_ORIGIN = 'https://api.anthropic.com'; + +/** + * Whether a request targets a Claude endpoint this installation already uses: + * ANTHROPIC_API_URL (empty means api.anthropic.com), or OCR_API_URL while + * Claude is the OCR provider. A stored key is only substituted for an empty + * key field when this holds; otherwise any signed-in caller could name their + * own host and receive the saved key in the x-api-key header. Typed keys are + * not affected. An empty URL passes: the request then goes to the configured + * proxy or to api.anthropic.com, both of which already hold the key. + * + * @param {string} requestedUrl - the URL the request names, empty for none + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +function isConfiguredAnthropicHost(requestedUrl, env = process.env) { + const origin = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) return ANTHROPIC_DEFAULT_ORIGIN; + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}`.toLowerCase(); + } catch { + return null; + } + }; + + if (!String(requestedUrl || '').trim()) return true; + const requested = origin(requestedUrl); + if (!requested) return false; + + const configured = [origin(env.ANTHROPIC_API_URL)]; + const ocrIsClaude = + String(env.OCR_PROVIDER || '') + .trim() + .toLowerCase() === 'anthropic'; + if (ocrIsClaude && String(env.OCR_API_URL || '').trim()) { + configured.push(origin(env.OCR_API_URL)); + } + return configured.includes(requested); +} + /** * The visible answer of a Messages response: every text block, in order. * Thinking blocks are no part of it, and a response can begin with them, so @@ -436,6 +479,7 @@ module.exports = { anthropicThinkingParams, anthropicMaxTokens, createAnthropicClient, + isConfiguredAnthropicHost, anthropicMessageText, mapAnthropicUsage, assertAnthropicCompleted, diff --git a/services/setupService.js b/services/setupService.js index 82d2132c..475f2ca1 100644 --- a/services/setupService.js +++ b/services/setupService.js @@ -9,6 +9,7 @@ const { ANTHROPIC_DEFAULT_MODEL, ANTHROPIC_SUGGESTED_MODELS, createAnthropicClient, + isConfiguredAnthropicHost, } = require('./anthropicSupport'); const CUSTOM_PROVIDER_FALLBACK_API_KEY = 'no-auth-required'; @@ -659,7 +660,9 @@ class SetupService { * * `anthropicApiKey` / `anthropicApiUrl` carry AI provider values that are * not saved yet (the setup wizard collects both before anything is - * written); the environment covers the ones that are. + * written); the environment covers the ones that are. The stored + * ANTHROPIC_API_KEY is only used when the URL is one already configured + * (see isConfiguredAnthropicHost). * * @param {object} options * @returns {{apiKey: string, apiUrl: string}} @@ -667,17 +670,17 @@ class SetupService { resolveAnthropicOcrConnection(options = {}) { const firstSet = (...values) => values.map((value) => String(value || '').trim()).find(Boolean) || ''; + const apiUrl = firstSet( + options.apiUrl, + options.anthropicApiUrl, + process.env.ANTHROPIC_API_URL + ); + const storedKey = isConfiguredAnthropicHost(apiUrl) + ? process.env.ANTHROPIC_API_KEY + : ''; return { - apiKey: firstSet( - options.apiKey, - options.anthropicApiKey, - process.env.ANTHROPIC_API_KEY - ), - apiUrl: firstSet( - options.apiUrl, - options.anthropicApiUrl, - process.env.ANTHROPIC_API_URL - ), + apiKey: firstSet(options.apiKey, options.anthropicApiKey, storedKey), + apiUrl, }; } diff --git a/tests/test-anthropic-settings.js b/tests/test-anthropic-settings.js index 270fbd80..3f958296 100644 --- a/tests/test-anthropic-settings.js +++ b/tests/test-anthropic-settings.js @@ -436,6 +436,7 @@ function startFakeAnthropic() { setScenario({ PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes', ANTHROPIC_API_KEY: GOOD_KEY, + ANTHROPIC_API_URL: fake.url, }); let response = await request('POST', '/api/settings/ai/models', { aiProvider: 'anthropic', @@ -461,6 +462,49 @@ function startFakeAnthropic() { assert.match(body.message, /suggested Claude models/); }); + await test('the stored Claude key never goes to a host that is not configured', async () => { + // The fake answers on 127.0.0.1 and localhost alike; only the stored + // URL names one of them, so the other counts as somebody else's host. + const otherHost = fake.url.includes('127.0.0.1') + ? fake.url.replace('127.0.0.1', 'localhost') + : fake.url.replace('localhost', '127.0.0.1'); + setScenario({ + PAPERLESS_AI_SETUP_ALLOW_LOCALHOST: 'yes', + ANTHROPIC_API_KEY: GOOD_KEY, + ANTHROPIC_API_URL: fake.url, + OCR_PROVIDER: 'anthropic', + OCR_API_KEY: GOOD_KEY, + }); + + let response = await request('POST', '/api/settings/ai/models', { + aiProvider: 'anthropic', + apiUrl: otherHost, + token: '', + }); + let body = await response.json(); + assert.strictEqual(response.status, 200); + assert.strictEqual(body.live, false, 'AI: no stored key for a new host'); + + response = await request('POST', '/api/settings/ocr/models', { + provider: 'anthropic', + apiUrl: otherHost, + apiKey: '', + }); + body = await response.json(); + assert.strictEqual(response.status, 200); + assert.strictEqual(body.live, false, 'OCR: no stored key for a new host'); + + const connection = setupService.resolveAnthropicOcrConnection({ + anthropicApiUrl: otherHost, + }); + assert.strictEqual(connection.apiKey, ''); + assert.strictEqual( + setupService.resolveAnthropicOcrConnection({}).apiKey, + GOOD_KEY, + 'the configured URL still gets the stored key' + ); + }); + // ── validateOcrConfig for Claude OCR ──────────────────────────────────── await test('validateOcrConfig(anthropic) runs one OCR round trip with the typed key', async () => { setScenario({ ANTHROPIC_API_KEY: AI_KEY }); diff --git a/tests/test-ocr-model-fetch-key.js b/tests/test-ocr-model-fetch-key.js index b646b42c..93179cda 100644 --- a/tests/test-ocr-model-fetch-key.js +++ b/tests/test-ocr-model-fetch-key.js @@ -117,7 +117,7 @@ check('every OCR route resolves the key before using it', () => { // which hands every other provider to resolveOcrApiKey() unchanged. const calls = ( source.match( - /resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey\s*\)/g + /resolveOcrApiKeyForProvider\(\s*req\.body\?\.provider,\s*req\.body\?\.apiKey,\s*req\.body\?\.apiUrl \|\| req\.body\?\.anthropicApiUrl\s*\)/g ) || [] ).length; assert.strictEqual( From 53fd7d87c6adca7e0a73d408fa98ffb2e24ad4e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:55:59 +0000 Subject: [PATCH 3/4] chore(release): changelog for single sign-on and the new settings page Background: Release v2026.10.01 now ships everything on nightly, including single sign-on (contributed in #372), the regrouped settings page and the auth fixes that came with #372. The changelog block only listed the Anthropic work. Changes: - Add entries for OpenID Connect single sign-on (credited to @krauseerl, #372), SSO on the settings page, the regrouped settings page, the Claude model dropdown and the login, TOTP and health hardening. Testing: - node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed - eslint and prettier on config/changelog.js clean; OpenAPI unchanged Impact: Changelog text only. Upstream Status: Not applicable (fork-specific release notes). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4 --- config/changelog.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/changelog.js b/config/changelog.js index 707e605d..0bf3de73 100644 --- a/config/changelog.js +++ b/config/changelog.js @@ -150,6 +150,11 @@ const RELEASES = [ 'New: Claude as OCR provider. It reads PDFs and images directly and uses the API key and model of the AI provider unless OCR has its own', 'Fix: Saving the settings no longer signs everybody out. The restart after a save created a new sign-in key, so every open session ended, and a page left open only showed vague errors such as "Model discovery failed". A session that ends now sends the page to the login screen', 'Fix: The manual tagging page uses the current design again. Its buttons, cards, document preview and tag chips were still built on styles that no longer exist', + 'New: Single sign-on with any OpenID Connect provider (Authentik, Keycloak, Entra ID and others). Contributed by @krauseerl in #372. E-mail addresses the provider marks as unverified are refused unless OIDC_ALLOW_UNVERIFIED_EMAIL=yes', + 'New: Single sign-on can be set up on the settings page, including a "Test connection" button. Values set by the container stay locked there', + 'Improvement: The settings page shows one topic at a time, grouped in a sidebar, with a search field and a save bar that counts unsaved changes across all topics', + 'Improvement: The Claude model is picked from a dropdown filled by "Fetch Models", like every other model', + 'Security: The login no longer reveals whether a username exists, a two-factor code is accepted only once, and the health endpoint keeps scanner and Paperless-ngx details behind authentication. Contributed by @krauseerl', ], }, ]; From 281f1dd59536c0017a80ad3255e06a71717848c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:59:28 +0000 Subject: [PATCH 4/4] fix(oidc): strip trailing slashes from the issuer URL in linear time Background: CodeQL flagged the /\/+$/ regex in oidcService.stripTrailingSlash() as a polynomial regular expression on user-provided data (the issuer URL from the settings page and the SSO test endpoint). Changes: - stripTrailingSlash() delegates to stripTrailingSlashes() from services/serviceUtils.js, which walks back over '/' characters. Testing: - node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed - eslint and prettier on services/oidcService.js clean Impact: Same result for every input, no backtracking. Upstream Status: Not applicable (OIDC exists only in this fork). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4 --- services/oidcService.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/oidcService.js b/services/oidcService.js index 9f84aa29..3eba124b 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -18,6 +18,7 @@ */ const config = require('../config/config'); +const { stripTrailingSlashes } = require('./serviceUtils'); // Loaded on first use, not at startup: an install that never turns SSO on // keeps running even on an image whose dependencies predate openid-client. @@ -43,7 +44,7 @@ const TOKEN_AUTH_METHODS = [ ]; function stripTrailingSlash(value) { - return String(value).replace(/\/+$/, ''); + return stripTrailingSlashes(String(value)); } /**