bug fixes

This commit is contained in:
seniorswe
2025-09-29 00:51:41 -04:00
parent b4d007c163
commit 255b2ccb66
12 changed files with 230 additions and 73 deletions

View File

@@ -144,6 +144,7 @@ async def cors_check(request: Request, body: CorsCheckRequest):
"origin": origin,
"method": method,
"request_headers": requested_headers,
"request_headers_normalized": requested_lower,
"with_credentials": with_credentials,
},
"preflight": {

View File

@@ -24,6 +24,16 @@ class ApiService:
Onboard an API to the platform.
"""
logger.info(request_id + " | Creating API: " + data.api_name + " " + data.api_version)
# Prevent unsafe combination: public API with credits enabled
try:
if getattr(data, 'api_public', False) and getattr(data, 'api_credits_enabled', False):
return ResponseModel(
status_code=400,
error_code='API013',
error_message='Public API cannot have credits enabled'
).dict()
except Exception:
pass
cache_key = f"{data.api_name}/{data.api_version}"
existing = doorman_cache.get_cache('api_cache', cache_key)
if not existing:
@@ -100,6 +110,18 @@ class ApiService:
doorman_cache.delete_cache('api_cache', doorman_cache.get_cache('api_id_cache', f"/{api_name}/{api_version}"))
doorman_cache.delete_cache('api_id_cache', f"/{api_name}/{api_version}")
not_null_data = {k: v for k, v in data.dict().items() if v is not None}
# Validate unsafe combination on the desired state (existing + updates)
try:
desired_public = bool(not_null_data.get('api_public', api.get('api_public')))
desired_credits = bool(not_null_data.get('api_credits_enabled', api.get('api_credits_enabled')))
if desired_public and desired_credits:
return ResponseModel(
status_code=400,
error_code='API013',
error_message='Public API cannot have credits enabled'
).dict()
except Exception:
pass
if not_null_data:
update_result = api_collection.update_one(
{'api_name': api_name, 'api_version': api_version},

View File

@@ -125,9 +125,13 @@ class GatewayService:
current_time = backend_end_time = None
try:
if not url and not method:
match = re.match(r"([^/]+/v\d+)", path)
api_name_version = '/' + match.group(1) if match else ""
endpoint_uri = re.sub(r"^[^/]+/v\d+/", "", path)
# Parse API name/version without regex to avoid ReDoS and be consistent with preflight
parts = [p for p in (path or '').split('/') if p]
api_name_version = ''
endpoint_uri = ''
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
api_name_version = f"/{parts[0]}/{parts[1]}"
endpoint_uri = '/'.join(parts[2:])
api_key = doorman_cache.get_cache('api_id_cache', api_name_version)
api = await api_util.get_api(api_key, api_name_version)
if not api:
@@ -150,8 +154,8 @@ class GatewayService:
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
method = request.method.upper()
retry = api.get('api_allowed_retry_count') or 0
# Enforce credits only for non-public APIs
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
# Enforce credits only for non-public APIs and when we have an authenticated user
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
if not await credit_util.deduct_credit(api.get('api_credit_group'), username):
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
current_time = time.time() * 1000
@@ -162,8 +166,8 @@ class GatewayService:
ai_token_headers = await credit_util.get_credit_api_header(api.get('api_credit_group'))
if ai_token_headers:
headers[ai_token_headers[0]] = ai_token_headers[1]
# Skip user-specific key injection for public APIs
if not bool(api.get('api_public')):
# Skip user-specific key injection when public or no authenticated user
if username and not bool(api.get('api_public')):
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
if user_specific_api_key:
headers[ai_token_headers[0]] = user_specific_api_key
@@ -262,9 +266,13 @@ class GatewayService:
current_time = backend_end_time = None
try:
if not url:
match = re.match(r"([^/]+/v\d+)", path)
api_name_version = '/' + match.group(1) if match else ""
endpoint_uri = re.sub(r"^[^/]+/v\d+/", "", path)
# Parse API name/version without regex to avoid ReDoS
parts = [p for p in (path or '').split('/') if p]
api_name_version = ''
endpoint_uri = ''
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
api_name_version = f"/{parts[0]}/{parts[1]}"
endpoint_uri = '/'.join(parts[2:])
api_key = doorman_cache.get_cache('api_id_cache', api_name_version)
api = await api_util.get_api(api_key, api_name_version)
if not api:
@@ -286,7 +294,7 @@ class GatewayService:
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
logger.info(f"{request_id} | SOAP gateway to: {url}")
retry = api.get('api_allowed_retry_count') or 0
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
if not await credit_util.deduct_credit(api, username):
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
current_time = time.time() * 1000
@@ -385,7 +393,7 @@ class GatewayService:
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured')
url = server.rstrip('/')
retry = api.get('api_allowed_retry_count') or 0
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
if not await credit_util.deduct_credit(api.get('api_credit_group'), username):
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
current_time = time.time() * 1000
@@ -397,9 +405,10 @@ class GatewayService:
ai_token_headers = await credit_util.get_credit_api_header(api.get('api_credit_group'))
if ai_token_headers:
headers[ai_token_headers[0]] = ai_token_headers[1]
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
if user_specific_api_key:
headers[ai_token_headers[0]] = user_specific_api_key
if username and not bool(api.get('api_public')):
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
if user_specific_api_key:
headers[ai_token_headers[0]] = user_specific_api_key
if api.get('api_authorization_field_swap'):
headers[api.get('Authorization')] = headers.get(api.get('api_authorization_field_swap'))
body = await request.json()

View File

@@ -64,6 +64,7 @@ async def test_graphql_public_api_allows_unauthenticated(client, authed_client):
@pytest.mark.asyncio
async def test_public_api_bypasses_credits_check(client, authed_client):
name, ver = "pubcredits", "v1"
# Creating Public + Credits should be rejected now
cr = await authed_client.post(
"/platform/api",
json={
@@ -77,7 +78,23 @@ async def test_public_api_bypasses_credits_check(client, authed_client):
"api_credit_group": "any-group",
},
)
assert cr.status_code in (200, 201), cr.text
assert cr.status_code == 400
body = cr.json()
assert (body.get("error_code") or body.get("response", {}).get("error_code")) == "API013"
# Create instead as public without credits and verify unauthenticated access is not blocked by 401
cr2 = await authed_client.post(
"/platform/api",
json={
"api_name": name,
"api_version": ver,
"api_description": "Public REST",
"api_servers": ["http://upstream.invalid"],
"api_type": "REST",
"api_public": True,
},
)
assert cr2.status_code in (200, 201), cr2.text
ce = await authed_client.post(
"/platform/endpoint",
json={
@@ -89,9 +106,7 @@ async def test_public_api_bypasses_credits_check(client, authed_client):
},
)
assert ce.status_code in (200, 201), ce.text
r = await client.get(f"/api/rest/{name}/{ver}/ping")
# Should not 401 due to credits check since API is public
assert r.status_code != 401

View File

@@ -189,7 +189,7 @@ export default function ApiEndpointsPage() {
const loadEndpoints = async () => {
setLoading(true)
setError(null)
try {
const attempt = async () => {
if (!apiName || !apiVersion) {
// Fallback: find API by id via listing
const data = await getJson<any>(`${SERVER_URL}/platform/api/all`)
@@ -219,8 +219,17 @@ export default function ApiEndpointsPage() {
}
setEndpoints(list)
setAllEndpoints(list)
}
try {
await attempt()
} catch (e:any) {
setError(e?.message || 'Failed to load endpoints')
// Retry once on transient failures
try {
await new Promise(r => setTimeout(r, 200))
await attempt()
} catch (err:any) {
setError(err?.message || 'Failed to load endpoints')
}
} finally {
setLoading(false)
}

View File

@@ -78,6 +78,8 @@ const ApiDetailPage = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [publicConfirmOpen, setPublicConfirmOpen] = useState(false)
const [pendingPublicValue, setPendingPublicValue] = useState<boolean | null>(null)
const [pubCredsConfirmOpen, setPubCredsConfirmOpen] = useState(false)
const [pendingPubCredsField, setPendingPubCredsField] = useState<null | { field: 'api_public' | 'api_credits_enabled'; value: boolean }>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [deleting, setDeleting] = useState(false)
const toast = useToast()
@@ -342,6 +344,18 @@ const ApiDetailPage = () => {
setPublicConfirmOpen(true)
return
}
const currentPublic = (isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false
const currentCredits = (isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false
if (field === 'api_public' && value === true && currentCredits) {
setPendingPubCredsField({ field: 'api_public', value: true })
setPubCredsConfirmOpen(true)
return
}
if (field === 'api_credits_enabled' && value === true && (currentPublic || (field === 'api_public' && value === true))) {
setPendingPubCredsField({ field: 'api_credits_enabled', value: true })
setPubCredsConfirmOpen(true)
return
}
setEditData(prev => ({ ...prev, [field]: value }))
}
@@ -658,6 +672,20 @@ const ApiDetailPage = () => {
<h3 className="card-title">Basic Information</h3>
</div>
<div className="p-6 space-y-4">
{(((isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false) && ((isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false)) && (
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
Public + Credits: Anyone can call this API and the group API key will be injected. Per-user deductions/keys are skipped.
</div>
)}
{(
((isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false) === false &&
((isEditing ? (editData as any)?.api_auth_required : (api as any)?.api_auth_required) ?? true) === false &&
((isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false) === true
) && (
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
No Auth + Credits: Unauthenticated requests are allowed and the group API key will be injected. Per-user deductions/keys are skipped. Consider enabling Auth Required or disabling Credits.
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Name
@@ -782,6 +810,7 @@ const ApiDetailPage = () => {
checked={!!(editData as any).api_public}
onChange={(e) => handleInputChange('api_public' as any, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
disabled={!!(editData as any).api_credits_enabled}
/>
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Anyone with the URL can call this API</label>
</div>
@@ -823,6 +852,7 @@ const ApiDetailPage = () => {
checked={editData.api_credits_enabled || false}
onChange={(e) => handleInputChange('api_credits_enabled', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
disabled={!!(editData as any).api_public}
/>
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Enable API credits
@@ -1252,8 +1282,36 @@ const ApiDetailPage = () => {
setPendingPublicValue(null)
}}
/>
{/* Public + Credits confirmation */}
<ConfirmModal
open={pubCredsConfirmOpen}
title="Public API with Credits?"
message={<div>
<p className="mb-2">Enabling Credits on a Public API injects the group API key for anyone calling this API.</p>
<p className="text-amber-600">User-level deductions/keys are skipped for public/no-auth calls.</p>
</div>}
confirmLabel="Proceed"
onConfirm={() => {
setPubCredsConfirmOpen(false)
if (pendingPubCredsField) {
setEditData(prev => ({ ...prev, [pendingPubCredsField.field]: pendingPubCredsField.value as any }))
}
setPendingPubCredsField(null)
}}
onCancel={() => {
setPubCredsConfirmOpen(false)
setPendingPubCredsField(null)
}}
/>
</Layout>
)
}
{isEditing && (editData as any).api_credits_enabled && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Credits to change Public status.</p>
)}
export default ApiDetailPage
{isEditing && (editData as any).api_public && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Public to enable Credits.</p>
)}

View File

@@ -36,6 +36,8 @@ const AddApiPage = () => {
})
const [publicConfirmOpen, setPublicConfirmOpen] = useState(false)
const [pendingPublicValue, setPendingPublicValue] = useState<boolean | null>(null)
const [pubCredsConfirmOpen, setPubCredsConfirmOpen] = useState(false)
const [pendingPubCredsField, setPendingPubCredsField] = useState<null | { field: 'api_public' | 'api_credits_enabled'; value: boolean }>(null)
const [newServer, setNewServer] = useState('')
const [newRole, setNewRole] = useState('')
const [newGroup, setNewGroup] = useState('')
@@ -80,6 +82,17 @@ const AddApiPage = () => {
return
}
}
// Guard: Public + Credits enabled risk confirmation
if (name === 'api_public' && (e.target as HTMLInputElement).checked && (formData as any).api_credits_enabled) {
setPendingPubCredsField({ field: 'api_public', value: true })
setPubCredsConfirmOpen(true)
return
}
if (name === 'api_credits_enabled' && (e.target as HTMLInputElement).checked && ((formData as any).api_public || pendingPublicValue)) {
setPendingPubCredsField({ field: 'api_credits_enabled', value: true })
setPubCredsConfirmOpen(true)
return
}
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : (name === 'api_allowed_retry_count' ? Number(value || 0) : value)
@@ -175,6 +188,11 @@ const AddApiPage = () => {
<FormHelp docHref="/docs/using-fields.html#apis">Fill API name/version; these form the base path clients call.</FormHelp>
</div>
<div className="p-6 space-y-4">
{((formData as any).api_public && (formData as any).api_credits_enabled) && (
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
Public + Credits: Anyone can call this API and the group API key will be injected. Per-user deductions/keys are skipped.
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Public API <InfoTooltip text="Anyone with the URL can call this API. Auth, subscription, and group checks are skipped." /></label>
<div className="flex items-center">
@@ -185,12 +203,15 @@ const AddApiPage = () => {
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
checked={(formData as any).api_public || false}
onChange={handleChange}
disabled={loading}
disabled={loading || ((formData as any).api_credits_enabled === true)}
/>
<label htmlFor="api_public" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Anyone with the URL can call this API
</label>
</div>
{((formData as any).api_credits_enabled === true) && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Credits to change Public status.</p>
)}
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">Use with care. Authentication, subscriptions, and group checks are skipped.</p>
</div>
<div>
@@ -333,12 +354,15 @@ const AddApiPage = () => {
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
checked={formData.api_credits_enabled}
onChange={handleChange}
disabled={loading}
disabled={loading || ((formData as any).api_public === true)}
/>
<label htmlFor="api_credits_enabled" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enable API credits
</label>
</div>
{((formData as any).api_public === true) && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Public to enable Credits.</p>
)}
</div>
{formData.api_credits_enabled && (
<div>
@@ -573,6 +597,28 @@ const AddApiPage = () => {
setPendingPublicValue(null)
}}
/>
{/* Public + Credits confirmation */}
<ConfirmModal
open={pubCredsConfirmOpen}
title="Public API with Credits?"
message={<div>
<p className="mb-2">Enabling Credits on a Public API injects the group API key for anyone calling this API.</p>
<p className="text-amber-600">User-level deductions/keys are skipped for public/no-auth calls.</p>
</div>}
confirmLabel="Proceed"
onConfirm={() => {
setPubCredsConfirmOpen(false)
if (pendingPubCredsField) {
setFormData(prev => ({ ...prev, [pendingPubCredsField.field]: pendingPubCredsField.value as any }))
}
setPendingPubCredsField(null)
}}
onCancel={() => {
setPubCredsConfirmOpen(false)
setPendingPubCredsField(null)
}}
/>
</Layout>
)
}

View File

@@ -105,8 +105,14 @@ const GroupDetailPage = () => {
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`, editData)
// Refresh from server to get the latest canonical data
const refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
// Refresh from server to get the latest canonical data (retry once on transient failure)
let refreshedGroup: any
try {
refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
} catch (e) {
await new Promise(r => setTimeout(r, 200))
refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
}
setGroup(refreshedGroup)
setEditData(refreshedGroup)
// Keep sessionStorage in sync for back-navigation

View File

@@ -111,8 +111,14 @@ const RoleDetailPage = () => {
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`, editData)
// Refresh from server to get the latest canonical data
const rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
// Refresh from server to get the latest canonical data (retry once on transient failure)
let rolePayload: any
try {
rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
} catch (e) {
await new Promise(r => setTimeout(r, 200))
rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
}
setRole(rolePayload)
setEditData(rolePayload)
// Keep sessionStorage in sync for back-navigation

View File

@@ -107,23 +107,16 @@ const RoutingDetailPage = () => {
setSaving(true)
setError(null)
const response = await fetch(`${SERVER_URL}/platform/routing/${clientKey}`, {
method: 'PUT',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(editData)
})
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/routing/${clientKey}`, editData)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Failed to update routing')
// Refresh from server to get the latest canonical data (retry once on transient failure)
let refreshedRouting: any
try {
refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
} catch (e) {
await new Promise(r => setTimeout(r, 200))
refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
}
// Refresh from server to get the latest canonical data
const refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
setRouting(refreshedRouting)
sessionStorage.setItem('selectedRouting', JSON.stringify(refreshedRouting))
setIsEditing(false)

View File

@@ -140,8 +140,14 @@ const UserDetailPage = () => {
}
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`, editData)
// Refresh from server to get the latest canonical data
const refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
// Refresh from server to get the latest canonical data (retry once on transient failure)
let refreshedUser: any
try {
refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
} catch (e) {
await new Promise(r => setTimeout(r, 200))
refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
}
setUser(refreshedUser)
// Keep sessionStorage in sync for back-navigation
sessionStorage.setItem('selectedUser', JSON.stringify(refreshedUser))

View File

@@ -5,37 +5,23 @@ export function getCookie(name: string): string | null {
}
export async function fetchJson<T = any>(url: string, init: RequestInit = {}): Promise<T> {
const attempt = async (): Promise<T> => {
const headers: Record<string, string> = {
Accept: 'application/json',
...(init.headers as any)
}
const csrf = getCookie('csrf_token')
if (csrf) headers['X-CSRF-Token'] = csrf
const resp = await fetch(url, {
credentials: 'include',
...init,
headers
})
const data = await resp.json().catch(() => ({}))
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
if (!resp.ok) {
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
throw new Error(msg)
}
return unwrapped as T
const headers: Record<string, string> = {
Accept: 'application/json',
...(init.headers as any)
}
const csrf = getCookie('csrf_token')
if (csrf) headers['X-CSRF-Token'] = csrf
// Try once; on transient failure for GET, retry quickly once
try {
return await attempt()
} catch (e) {
const method = (init.method || 'GET').toString().toUpperCase()
if (method === 'GET') {
await new Promise(r => setTimeout(r, 200))
return await attempt()
}
throw e
const resp = await fetch(url, {
credentials: 'include',
...init,
headers
})
const data = await resp.json().catch(() => ({}))
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
if (!resp.ok) {
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
throw new Error(msg)
}
return unwrapped as T
}