feat(backend, frontend): make changes to use our security modules more effectively (#10123)

<!-- Clearly explain the need for these changes: -->
Doing the CASA Audit and this is something to check
### Changes 🏗️
- limits APIs to use their specific endpoints
- use expected trusted sources for each block and requests call
- Use cryptographically valid string comparisons
- Don't log secrets

<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Testing in dev branch once merged

---------

Co-authored-by: Swifty <craigswift13@gmail.com>
This commit is contained in:
Nicholas Tindle
2025-06-16 10:22:08 -05:00
committed by GitHub
parent f950f35af8
commit 81d3eb7c34
36 changed files with 312 additions and 102 deletions

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ autogpt_platform/backend/settings.py
*.ign.*
.test-contents
.claude/settings.local.json

View File

@@ -31,4 +31,5 @@ class APIKeyManager:
"""Verify if a provided API key matches the stored hash."""
if not provided_key.startswith(self.PREFIX):
return False
return hashlib.sha256(provided_key.encode()).hexdigest() == stored_hash
provided_hash = hashlib.sha256(provided_key.encode()).hexdigest()
return secrets.compare_digest(provided_hash, stored_hash)

View File

@@ -1,5 +1,6 @@
import inspect
import logging
import secrets
from typing import Any, Callable, Optional
from fastapi import HTTPException, Request, Security
@@ -93,7 +94,11 @@ class APIKeyValidator:
self.error_message = error_message
async def default_validator(self, api_key: str) -> bool:
return api_key == self.expected_token
if not self.expected_token:
raise ValueError(
"Expected Token Required to be set when uisng API Key Validator default validation"
)
return secrets.compare_digest(api_key, self.expected_token)
async def __call__(
self, request: Request, api_key: str = Security(APIKeyHeader)

View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -177,7 +177,7 @@ files = [
{file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
]
markers = {main = "python_version < \"3.11\"", dev = "python_full_version < \"3.11.3\""}
markers = {main = "python_version == \"3.10\"", dev = "python_full_version < \"3.11.3\""}
[[package]]
name = "attrs"
@@ -323,6 +323,21 @@ files = [
{file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
]
[[package]]
name = "click"
version = "8.2.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
@@ -375,7 +390,7 @@ description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version < \"3.11\""
markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
@@ -399,6 +414,27 @@ files = [
[package.extras]
tests = ["coverage", "coveralls", "dill", "mock", "nose"]
[[package]]
name = "fastapi"
version = "0.115.12"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"},
{file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.47.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "frozenlist"
version = "1.4.1"
@@ -895,6 +931,47 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "launchdarkly-eventsource"
version = "1.2.4"
description = "LaunchDarkly SSE Client"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "launchdarkly_eventsource-1.2.4-py3-none-any.whl", hash = "sha256:048ef8c4440d0d8219778661ee4d4b5e12aa6ed2c29a3004417ede44c2386e8c"},
{file = "launchdarkly_eventsource-1.2.4.tar.gz", hash = "sha256:b8b9342681f55e1d35c56243431cbbaca4eb9812d6785f8de204af322104e066"},
]
[package.dependencies]
urllib3 = ">=1.26.0,<3"
[[package]]
name = "launchdarkly-server-sdk"
version = "9.11.1"
description = "LaunchDarkly SDK for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "launchdarkly_server_sdk-9.11.1-py3-none-any.whl", hash = "sha256:128569cebf666dd115cc0ba03c48ff75f6acc9788301a7e2c3a54d06107e445a"},
{file = "launchdarkly_server_sdk-9.11.1.tar.gz", hash = "sha256:150e29656cb8c506d1967f3c59e62b69310d345ec27217640a6146dd1db5d250"},
]
[package.dependencies]
certifi = ">=2018.4.16"
expiringdict = ">=1.1.4"
launchdarkly-eventsource = ">=1.2.4,<2.0.0"
pyRFC3339 = ">=1.0"
semver = ">=2.10.2"
urllib3 = ">=1.26.0,<3"
[package.extras]
consul = ["python-consul (>=1.0.1)"]
dynamodb = ["boto3 (>=1.9.71)"]
redis = ["redis (>=2.10.5)"]
test-filesource = ["pyyaml (>=5.3.1)", "watchdog (>=3.0.0)"]
[[package]]
name = "multidict"
version = "6.1.0"
@@ -1412,6 +1489,18 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pyrfc3339"
version = "2.0.1"
description = "Generate and parse RFC 3339 timestamps"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d"},
{file = "pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b"},
]
[[package]]
name = "pytest"
version = "8.3.3"
@@ -1604,6 +1693,18 @@ files = [
{file = "ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6"},
]
[[package]]
name = "semver"
version = "3.0.4"
description = "Python helper for Semantic Versioning (https://semver.org)"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"},
{file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"},
]
[[package]]
name = "six"
version = "1.16.0"
@@ -1628,6 +1729,24 @@ files = [
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "starlette"
version = "0.46.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "storage3"
version = "0.11.0"
@@ -1704,7 +1823,7 @@ description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version < \"3.11\""
markers = "python_version == \"3.10\""
files = [
{file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"},
{file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"},
@@ -1755,6 +1874,26 @@ h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.34.3"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"},
{file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "websockets"
version = "12.0"
@@ -2037,4 +2176,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "78ebf65cdef769cfbe92fe204f01e32d219cca9ee5a6ca9e657aa0630be63802"
content-hash = "d92143928a88ca3a56ac200c335910eafac938940022fed8bd0d17c95040b54f"

View File

@@ -17,6 +17,9 @@ pyjwt = "^2.10.1"
pytest-asyncio = "^0.26.0"
pytest-mock = "^3.14.0"
supabase = "^2.15.1"
launchdarkly-server-sdk = "^9.11.1"
fastapi = "^0.115.12"
uvicorn = "^0.34.3"
[tool.poetry.group.dev.dependencies]
redis = "^5.2.1"

View File

@@ -9,7 +9,7 @@ from backend.blocks.exa._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class ContentRetrievalSettings(BaseModel):
@@ -79,7 +79,7 @@ class ExaContentsBlock(Block):
}
try:
response = requests.post(url, headers=headers, json=payload)
response = Requests().post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
yield "results", data.get("results", [])

View File

@@ -9,7 +9,7 @@ from backend.blocks.exa._auth import (
from backend.blocks.exa.helpers import ContentSettings
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class ExaSearchBlock(Block):
@@ -136,7 +136,7 @@ class ExaSearchBlock(Block):
payload[api_field] = value
try:
response = requests.post(url, headers=headers, json=payload)
response = Requests().post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
# Extract just the results array from the response

View File

@@ -8,7 +8,7 @@ from backend.blocks.exa._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
from .helpers import ContentSettings
@@ -120,7 +120,7 @@ class ExaFindSimilarBlock(Block):
payload[api_field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z")
try:
response = requests.post(url, headers=headers, json=payload)
response = Requests().post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
yield "results", data.get("results", [])

View File

@@ -114,9 +114,24 @@ class SendWebRequestBlock(Block):
body = input_data.body
if isinstance(body, str):
try:
body = json.loads(body)
except json.JSONDecodeError:
# plain text treat as formfield value instead
# Validate JSON string length to prevent DoS attacks
if len(body) > 10_000_000: # 10MB limit
raise ValueError("JSON body too large")
parsed_body = json.loads(body)
# Validate that parsed JSON is safe (basic object/array/primitive types)
if (
isinstance(parsed_body, (dict, list, str, int, float, bool))
or parsed_body is None
):
body = parsed_body
else:
# Unexpected type, treat as plain text
input_data.json_format = False
except (json.JSONDecodeError, ValueError):
# Invalid JSON or too large treat as formfield value instead
input_data.json_format = False
# ─── Prepare files (if any) ──────────────────────────────────

View File

@@ -5,7 +5,7 @@ from backend.blocks.hubspot._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class HubSpotCompanyBlock(Block):
@@ -45,7 +45,7 @@ class HubSpotCompanyBlock(Block):
}
if input_data.operation == "create":
response = requests.post(
response = Requests().post(
base_url, headers=headers, json={"properties": input_data.company_data}
)
result = response.json()
@@ -67,14 +67,14 @@ class HubSpotCompanyBlock(Block):
}
]
}
response = requests.post(search_url, headers=headers, json=search_data)
response = Requests().post(search_url, headers=headers, json=search_data)
result = response.json()
yield "company", result.get("results", [{}])[0]
yield "status", "retrieved"
elif input_data.operation == "update":
# First get company ID by domain
search_response = requests.post(
search_response = Requests().post(
f"{base_url}/search",
headers=headers,
json={
@@ -94,7 +94,7 @@ class HubSpotCompanyBlock(Block):
company_id = search_response.json().get("results", [{}])[0].get("id")
if company_id:
response = requests.patch(
response = Requests().patch(
f"{base_url}/{company_id}",
headers=headers,
json={"properties": input_data.company_data},

View File

@@ -5,7 +5,7 @@ from backend.blocks.hubspot._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class HubSpotContactBlock(Block):
@@ -45,7 +45,7 @@ class HubSpotContactBlock(Block):
}
if input_data.operation == "create":
response = requests.post(
response = Requests().post(
base_url, headers=headers, json={"properties": input_data.contact_data}
)
result = response.json()
@@ -68,13 +68,13 @@ class HubSpotContactBlock(Block):
}
]
}
response = requests.post(search_url, headers=headers, json=search_data)
response = Requests().post(search_url, headers=headers, json=search_data)
result = response.json()
yield "contact", result.get("results", [{}])[0]
yield "status", "retrieved"
elif input_data.operation == "update":
search_response = requests.post(
search_response = Requests().post(
f"{base_url}/search",
headers=headers,
json={
@@ -94,7 +94,7 @@ class HubSpotContactBlock(Block):
contact_id = search_response.json().get("results", [{}])[0].get("id")
if contact_id:
response = requests.patch(
response = Requests().patch(
f"{base_url}/{contact_id}",
headers=headers,
json={"properties": input_data.contact_data},

View File

@@ -7,7 +7,7 @@ from backend.blocks.hubspot._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class HubSpotEngagementBlock(Block):
@@ -66,7 +66,7 @@ class HubSpotEngagementBlock(Block):
}
}
response = requests.post(email_url, headers=headers, json=email_data)
response = Requests().post(email_url, headers=headers, json=email_data)
result = response.json()
yield "result", result
yield "status", "email_sent"
@@ -80,7 +80,7 @@ class HubSpotEngagementBlock(Block):
params = {"limit": 100, "after": from_date.isoformat()}
response = requests.get(engagement_url, headers=headers, params=params)
response = Requests().get(engagement_url, headers=headers, params=params)
engagements = response.json()
# Process engagement metrics

View File

@@ -12,7 +12,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.request import requests
from backend.util.request import Requests
TEST_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
@@ -267,7 +267,7 @@ class IdeogramModelBlock(Block):
}
try:
response = requests.post(url, json=data, headers=headers)
response = Requests().post(url, json=data, headers=headers)
return response.json()["data"][0]["url"]
except RequestException as e:
raise Exception(f"Failed to fetch image: {str(e)}")
@@ -280,14 +280,14 @@ class IdeogramModelBlock(Block):
try:
# Step 1: Download the image from the provided URL
image_response = requests.get(image_url)
image_response = Requests().get(image_url)
# Step 2: Send the downloaded image to the upscale API
files = {
"image_file": ("image.png", image_response.content, "image/png"),
}
response = requests.post(
response = Requests().post(
url,
headers=headers,
data={"image_request": "{}"},

View File

@@ -5,7 +5,7 @@ from backend.blocks.jina._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class JinaChunkingBlock(Block):
@@ -55,7 +55,7 @@ class JinaChunkingBlock(Block):
"max_chunk_length": str(input_data.max_chunk_length),
}
response = requests.post(url, headers=headers, json=data)
response = Requests().post(url, headers=headers, json=data)
result = response.json()
all_chunks.extend(result.get("chunks", []))

View File

@@ -5,7 +5,7 @@ from backend.blocks.jina._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
class JinaEmbeddingBlock(Block):
@@ -38,6 +38,6 @@ class JinaEmbeddingBlock(Block):
"Authorization": f"Bearer {credentials.api_key.get_secret_value()}",
}
data = {"input": input_data.texts, "model": input_data.model}
response = requests.post(url, headers=headers, json=data)
response = Requests().post(url, headers=headers, json=data)
embeddings = [e["embedding"] for e in response.json()["data"]]
yield "embeddings", embeddings

View File

@@ -1,7 +1,5 @@
from urllib.parse import quote
import requests
from backend.blocks.jina._auth import (
JinaCredentials,
JinaCredentialsField,
@@ -9,6 +7,7 @@ from backend.blocks.jina._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
class FactCheckerBlock(Block):

View File

@@ -13,7 +13,7 @@ from backend.data.model import (
SecretField,
)
from backend.integrations.providers import ProviderName
from backend.util.request import requests
from backend.util.request import Requests
TEST_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
@@ -160,7 +160,7 @@ class PublishToMediumBlock(Block):
"notifyFollowers": notify_followers,
}
response = requests.post(
response = Requests().post(
f"https://api.medium.com/v1/users/{author_id}/posts",
headers=headers,
json=data,

View File

@@ -5,7 +5,7 @@ from backend.blocks.nvidia._auth import (
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.request import Requests
from backend.util.type import MediaFileType
@@ -59,7 +59,7 @@ class NvidiaDeepfakeDetectBlock(Block):
}
try:
response = requests.post(url, headers=headers, json=payload)
response = Requests().post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()

View File

@@ -121,11 +121,10 @@ class ScreenshotWebPageBlock(Block):
"""
Takes a screenshot using the ScreenshotOne API
"""
api = Requests(trusted_origins=["https://api.screenshotone.com"])
api = Requests()
# Build API URL with parameters
# Build API parameters
params = {
"access_key": credentials.api_key.get_secret_value(),
"url": url,
"viewport_width": viewport_width,
"viewport_height": viewport_height,
@@ -137,7 +136,14 @@ class ScreenshotWebPageBlock(Block):
"cache": str(cache).lower(),
}
response = api.get("https://api.screenshotone.com/take", params=params)
# Use header-based authentication instead of query parameter
headers = {
"X-Access-Key": credentials.api_key.get_secret_value(),
}
response = api.get(
"https://api.screenshotone.com/take", params=params, headers=headers
)
return {
"image": store_media_file(

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from backend.data.block import Block
from backend.util.request import requests
from backend.util.request import Requests
from ._api import Color, CustomerDetails, OrderItem, Profile
@@ -16,7 +16,7 @@ class Slant3DBlockBase(Block):
def _make_request(self, method: str, endpoint: str, api_key: str, **kwargs) -> Dict:
url = f"{self.BASE_URL}/{endpoint}"
response = requests.request(
response = Requests().request(
method=method, url=url, headers=self._get_headers(api_key), **kwargs
)

View File

@@ -1,11 +1,10 @@
import uuid
from typing import List
import requests as baserequests
from backend.data.block import BlockOutput, BlockSchema
from backend.data.model import APIKeyCredentials, SchemaField
from backend.util import settings
from backend.util.request import req
from backend.util.settings import BehaveAs
from ._api import (
@@ -181,7 +180,7 @@ class Slant3DEstimateOrderBlock(Slant3DBlockBase):
yield "total_price", result["totalPrice"]
yield "shipping_cost", result["shippingCost"]
yield "printing_cost", result["printingCost"]
except baserequests.HTTPError as e:
except req.HTTPError as e:
yield "error", str(f"Error estimating order: {e} {e.response.text}")
raise

View File

@@ -121,16 +121,17 @@ def reddit(server_address: str):
"""
Create an event graph
"""
import requests
from backend.usecases.reddit_marketing import create_test_graph
from backend.util.request import Requests
test_graph = create_test_graph()
url = f"{server_address}/graphs"
headers = {"Content-Type": "application/json"}
data = test_graph.model_dump_json()
response = requests.post(url, headers=headers, data=data)
response = Requests(trusted_origins=[server_address]).post(
url, headers=headers, data=data
)
graph_id = response.json()["id"]
print(f"Graph created with ID: {graph_id}")
@@ -142,16 +143,18 @@ def populate_db(server_address: str):
"""
Create an event graph
"""
import requests
from backend.usecases.sample import create_test_graph
from backend.util.request import Requests
test_graph = create_test_graph()
url = f"{server_address}/graphs"
headers = {"Content-Type": "application/json"}
data = test_graph.model_dump_json()
response = requests.post(url, headers=headers, data=data)
response = Requests(trusted_origins=[server_address]).post(
url, headers=headers, data=data
)
graph_id = response.json()["id"]
@@ -159,7 +162,9 @@ def populate_db(server_address: str):
execute_url = f"{server_address}/graphs/{response.json()['id']}/execute"
text = "Hello, World!"
input_data = {"input": text}
response = requests.post(execute_url, headers=headers, json=input_data)
response = Requests(trusted_origins=[server_address]).post(
execute_url, headers=headers, json=input_data
)
schedule_url = f"{server_address}/graphs/{graph_id}/schedules"
data = {
@@ -167,7 +172,9 @@ def populate_db(server_address: str):
"cron": "*/5 * * * *",
"input_data": {"input": "Hello, World!"},
}
response = requests.post(schedule_url, headers=headers, json=data)
response = Requests(trusted_origins=[server_address]).post(
schedule_url, headers=headers, json=data
)
print("Database populated with: \n- graph\n- execution\n- schedule")
@@ -178,21 +185,25 @@ def graph(server_address: str):
"""
Create an event graph
"""
import requests
from backend.usecases.sample import create_test_graph
from backend.util.request import Requests
url = f"{server_address}/graphs"
headers = {"Content-Type": "application/json"}
data = create_test_graph().model_dump_json()
response = requests.post(url, headers=headers, data=data)
response = Requests(trusted_origins=[server_address]).post(
url, headers=headers, data=data
)
if response.status_code == 200:
print(response.json()["id"])
execute_url = f"{server_address}/graphs/{response.json()['id']}/execute"
text = "Hello, World!"
input_data = {"input": text}
response = requests.post(execute_url, headers=headers, json=input_data)
response = Requests(trusted_origins=[server_address]).post(
execute_url, headers=headers, json=input_data
)
else:
print("Failed to send graph")
@@ -206,12 +217,15 @@ def execute(graph_id: str, content: dict):
"""
Create an event graph
"""
import requests
from backend.util.request import Requests
headers = {"Content-Type": "application/json"}
execute_url = f"http://0.0.0.0:8000/graphs/{graph_id}/execute"
requests.post(execute_url, headers=headers, json=content)
Requests(trusted_origins=["http://0.0.0.0:8000"]).post(
execute_url, headers=headers, json=content
)
@test.command()

View File

@@ -398,7 +398,7 @@ class IntegrationCredentialsStore:
(
state
for state in oauth_states
if state.token == token
if secrets.compare_digest(state.token, token)
and state.provider == provider
and state.expires_at > now.timestamp()
),

View File

@@ -4,7 +4,7 @@ from urllib.parse import urlencode
from backend.data.model import OAuth2Credentials
from backend.integrations.providers import ProviderName
from backend.util.request import requests
from backend.util.request import Requests
from .base import BaseOAuthHandler
@@ -59,7 +59,7 @@ class GitHubOAuthHandler(BaseOAuthHandler):
"X-GitHub-Api-Version": "2022-11-28",
}
requests.delete(
Requests().delete(
url=self.revoke_url.format(client_id=self.client_id),
auth=(self.client_id, self.client_secret),
headers=headers,
@@ -89,7 +89,7 @@ class GitHubOAuthHandler(BaseOAuthHandler):
**params,
}
headers = {"Accept": "application/json"}
response = requests.post(self.token_url, data=request_body, headers=headers)
response = Requests().post(self.token_url, data=request_body, headers=headers)
token_data: dict = response.json()
username = self._request_username(token_data["access_token"])
@@ -132,7 +132,7 @@ class GitHubOAuthHandler(BaseOAuthHandler):
"X-GitHub-Api-Version": "2022-11-28",
}
response = requests.get(url, headers=headers)
response = Requests().get(url, headers=headers)
if not response.ok:
return None

View File

@@ -76,7 +76,7 @@ class GoogleOAuthHandler(BaseOAuthHandler):
logger.debug(f"Scopes granted by Google: {granted_scopes}")
google_creds = flow.credentials
logger.debug(f"Received credentials: {google_creds}")
logger.debug("Received credentials")
logger.debug("Requesting user email")
username = self._request_email(google_creds)

View File

@@ -7,7 +7,7 @@ from pydantic import SecretStr
from backend.blocks.linear._api import LinearAPIException
from backend.data.model import APIKeyCredentials, OAuth2Credentials
from backend.integrations.providers import ProviderName
from backend.util.request import requests
from backend.util.request import Requests
from .base import BaseOAuthHandler
@@ -53,7 +53,7 @@ class LinearOAuthHandler(BaseOAuthHandler):
"Authorization": f"Bearer {credentials.access_token.get_secret_value()}"
}
response = requests.post(self.revoke_url, headers=headers)
response = Requests().post(self.revoke_url, headers=headers)
if not response.ok:
try:
error_data = response.json()
@@ -95,7 +95,7 @@ class LinearOAuthHandler(BaseOAuthHandler):
headers = {
"Content-Type": "application/x-www-form-urlencoded"
} # Correct header for token request
response = requests.post(self.token_url, data=request_body, headers=headers)
response = Requests().post(self.token_url, data=request_body, headers=headers)
if not response.ok:
try:

View File

@@ -4,7 +4,7 @@ from urllib.parse import urlencode
from backend.data.model import OAuth2Credentials
from backend.integrations.providers import ProviderName
from backend.util.request import requests
from backend.util.request import Requests
from .base import BaseOAuthHandler
@@ -52,7 +52,7 @@ class NotionOAuthHandler(BaseOAuthHandler):
"Authorization": f"Basic {auth_str}",
"Accept": "application/json",
}
response = requests.post(self.token_url, json=request_body, headers=headers)
response = Requests().post(self.token_url, json=request_body, headers=headers)
token_data = response.json()
# Email is only available for non-bot users
email = (

View File

@@ -1,10 +1,9 @@
import urllib.parse
from typing import ClassVar, Optional
import requests
from backend.data.model import OAuth2Credentials, ProviderName
from backend.integrations.oauth.base import BaseOAuthHandler
from backend.util.request import Requests
class TodoistOAuthHandler(BaseOAuthHandler):
@@ -48,12 +47,12 @@ class TodoistOAuthHandler(BaseOAuthHandler):
"redirect_uri": self.redirect_uri,
}
response = requests.post(self.TOKEN_URL, data=data)
response = Requests().post(self.TOKEN_URL, data=data)
response.raise_for_status()
tokens = response.json()
response = requests.post(
response = Requests().post(
"https://api.todoist.com/sync/v9/sync",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
data={"sync_token": "*", "resource_types": '["user"]'},

View File

@@ -2,10 +2,9 @@ import time
import urllib.parse
from typing import ClassVar, Optional
import requests
from backend.data.model import OAuth2Credentials, ProviderName
from backend.integrations.oauth.base import BaseOAuthHandler
from backend.util.request import Requests, req
class TwitterOAuthHandler(BaseOAuthHandler):
@@ -78,7 +77,9 @@ class TwitterOAuthHandler(BaseOAuthHandler):
auth = (self.client_id, self.client_secret)
response = requests.post(self.TOKEN_URL, headers=headers, data=data, auth=auth)
response = Requests().post(
self.TOKEN_URL, headers=headers, data=data, auth=auth
)
response.raise_for_status()
tokens = response.json()
@@ -102,7 +103,7 @@ class TwitterOAuthHandler(BaseOAuthHandler):
params = {"user.fields": "username"}
response = requests.get(
response = Requests().get(
f"{self.USERNAME_URL}?{urllib.parse.urlencode(params)}", headers=headers
)
response.raise_for_status()
@@ -122,13 +123,12 @@ class TwitterOAuthHandler(BaseOAuthHandler):
auth = (self.client_id, self.client_secret)
response = requests.post(self.TOKEN_URL, headers=header, data=data, auth=auth)
response = Requests().post(self.TOKEN_URL, headers=header, data=data, auth=auth)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print("HTTP Error:", e)
print("Response Content:", response.text)
except req.exceptions.HTTPError:
print(f"HTTP Error: {response.status_code}")
raise
tokens = response.json()
@@ -159,13 +159,14 @@ class TwitterOAuthHandler(BaseOAuthHandler):
auth = (self.client_id, self.client_secret)
response = requests.post(self.REVOKE_URL, headers=header, data=data, auth=auth)
response = Requests().post(
self.REVOKE_URL, headers=header, data=data, auth=auth
)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print("HTTP Error:", e)
print("Response Content:", response.text)
except req.exceptions.HTTPError:
print(f"HTTP Error: {response.status_code}")
raise
return response.status_code == 200

View File

@@ -2,13 +2,13 @@ import hashlib
import hmac
import logging
import requests
from fastapi import HTTPException, Request
from strenum import StrEnum
from backend.data import integrations
from backend.data.model import Credentials
from backend.integrations.providers import ProviderName
from backend.util.request import Requests, req
from ._base import BaseWebhooksManager
@@ -73,7 +73,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
repo, github_hook_id = webhook.resource, webhook.provider_webhook_id
ping_url = f"{self.GITHUB_API_URL}/repos/{repo}/hooks/{github_hook_id}/pings"
response = requests.post(ping_url, headers=headers)
response = Requests().post(ping_url, headers=headers)
if response.status_code != 204:
error_msg = extract_github_error_msg(response)
@@ -110,7 +110,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
},
}
response = requests.post(
response = Requests().post(
f"{self.GITHUB_API_URL}/repos/{resource}/hooks",
headers=headers,
json=webhook_data,
@@ -153,7 +153,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
f"Unsupported webhook type '{webhook.webhook_type}'"
)
response = requests.delete(delete_url, headers=headers)
response = Requests().delete(delete_url, headers=headers)
if response.status_code not in [204, 404]:
# 204 means successful deletion, 404 means the webhook was already deleted
@@ -166,7 +166,7 @@ class GithubWebhooksManager(BaseWebhooksManager):
# --8<-- [end:GithubWebhooksManager]
def extract_github_error_msg(response: requests.Response) -> str:
def extract_github_error_msg(response: req.Response) -> str:
error_msgs = []
resp = response.json()
if resp.get("message"):

View File

@@ -1,12 +1,12 @@
import logging
import requests
from fastapi import Request
from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials
from backend.integrations.providers import ProviderName
from backend.integrations.webhooks._base import BaseWebhooksManager
from backend.util.request import Requests
logger = logging.getLogger(__name__)
@@ -39,7 +39,7 @@ class Slant3DWebhooksManager(BaseWebhooksManager):
# Slant3D's API doesn't use events list, just register for all order updates
payload = {"endPoint": ingress_url}
response = requests.post(
response = Requests().post(
f"{self.BASE_URL}/customer/webhookSubscribe", headers=headers, json=payload
)

View File

@@ -36,11 +36,11 @@ logger = logging.getLogger(__name__)
@router.post("/unsubscribe")
async def unsubscribe_via_one_click(token: Annotated[str, Query()]):
logger.info(f"Received unsubscribe request from One Click Unsubscribe: {token}")
logger.info("Received unsubscribe request from One Click Unsubscribe")
try:
await unsubscribe_user_by_token(token)
except Exception as e:
logger.exception("Unsubscribe token %s failed: %s", token, e)
logger.exception("Unsubscribe failed: %s", e)
raise HTTPException(
status_code=500,
detail={"message": str(e), "hint": "Verify Postmark token settings."},

View File

@@ -303,13 +303,16 @@ develop = true
[package.dependencies]
colorama = "^0.4.6"
expiringdict = "^1.2.2"
fastapi = "^0.115.12"
google-cloud-logging = "^3.12.1"
launchdarkly-server-sdk = "^9.11.1"
pydantic = "^2.11.4"
pydantic-settings = "^2.9.1"
pyjwt = "^2.10.1"
pytest-asyncio = "^0.26.0"
pytest-mock = "^3.14.0"
supabase = "^2.15.1"
uvicorn = "^0.34.3"
[package.source]
type = "directory"

View File

@@ -11,13 +11,25 @@ async function shouldShowOnboarding() {
);
}
// Validate redirect URL to prevent open redirect attacks
function validateRedirectUrl(url: string): string {
// Only allow relative URLs that start with /
if (url.startsWith("/") && !url.startsWith("//")) {
return url;
}
// Default to home page for any invalid URLs
return "/";
}
// Handle the callback to complete the user session login
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
// if "next" is in param, use it as the redirect URL
let next = searchParams.get("next") ?? "/";
const nextParam = searchParams.get("next") ?? "/";
// Validate redirect URL to prevent open redirect attacks
let next = validateRedirectUrl(nextParam);
if (code) {
const supabase = await getServerSupabase();

View File

@@ -27,6 +27,7 @@ import {
cn,
getValue,
hasNonNullNonObjectValue,
isObject,
parseKeys,
setNestedProperty,
} from "@/lib/utils";
@@ -435,8 +436,15 @@ export const CustomNode = React.memo(
if (activeKey) {
try {
const parsedValue = JSON.parse(value);
handleInputChange(activeKey, parsedValue);
// Validate that the parsed value is safe before using it
if (isObject(parsedValue) || Array.isArray(parsedValue)) {
handleInputChange(activeKey, parsedValue);
} else {
// For primitive values, use the original string
handleInputChange(activeKey, value);
}
} catch (error) {
// If JSON parsing fails, treat as plain text
handleInputChange(activeKey, value);
}
}

View File

@@ -396,3 +396,8 @@ export function getValue(key: string, value: any) {
export function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
/** Chech if a value is an object or not */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}