2026-01-27 08:24:22 +00:00
|
|
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
|
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
# See the LICENSE file for details.
|
|
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
# Python imports
|
2026-03-03 14:05:34 +00:00
|
|
|
import logging
|
2024-05-08 17:31:20 +00:00
|
|
|
import os
|
|
|
|
|
import uuid
|
2025-12-09 10:18:27 +00:00
|
|
|
from io import BytesIO
|
2024-05-08 17:31:20 +00:00
|
|
|
|
2026-03-03 14:05:34 +00:00
|
|
|
from django.conf import settings
|
|
|
|
|
from django.core.exceptions import ValidationError
|
|
|
|
|
from django.core.validators import validate_email
|
fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
2026-05-30 18:42:23 +00:00
|
|
|
from plane.utils.url_security import pinned_fetch_following_redirects
|
2026-03-03 14:05:34 +00:00
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
# Django imports
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
|
|
|
|
# Third party imports
|
|
|
|
|
from zxcvbn import zxcvbn
|
|
|
|
|
|
2026-06-24 09:10:24 +00:00
|
|
|
from plane.bgtasks.user_activation_email_task import user_activation_email
|
|
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
# Module imports
|
2026-03-03 14:05:34 +00:00
|
|
|
from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite
|
2024-05-08 17:31:20 +00:00
|
|
|
from plane.license.utils.instance_value import get_configuration_value
|
2026-03-03 14:05:34 +00:00
|
|
|
from plane.settings.storage import S3Storage
|
|
|
|
|
from plane.utils.exception_logger import log_exception
|
2026-06-24 09:10:24 +00:00
|
|
|
from plane.utils.host import base_host
|
2025-04-02 17:39:27 +00:00
|
|
|
from plane.utils.ip_address import get_client_ip
|
2026-03-03 14:05:34 +00:00
|
|
|
|
|
|
|
|
from .error import AUTHENTICATION_ERROR_CODES, AuthenticationException
|
2024-05-08 17:31:20 +00:00
|
|
|
|
2025-05-19 12:04:46 +00:00
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
class Adapter:
|
|
|
|
|
"""Common interface for all auth providers"""
|
|
|
|
|
|
2024-05-17 09:57:34 +00:00
|
|
|
def __init__(self, request, provider, callback=None):
|
2024-05-08 17:31:20 +00:00
|
|
|
self.request = request
|
|
|
|
|
self.provider = provider
|
2024-05-17 09:57:34 +00:00
|
|
|
self.callback = callback
|
2024-05-08 17:31:20 +00:00
|
|
|
self.token_data = None
|
|
|
|
|
self.user_data = None
|
2026-03-03 14:05:34 +00:00
|
|
|
self.logger = logging.getLogger("plane.authentication")
|
2024-05-08 17:31:20 +00:00
|
|
|
|
|
|
|
|
def get_user_token(self, data, headers=None):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
def get_user_response(self):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
def set_token_data(self, data):
|
|
|
|
|
self.token_data = data
|
|
|
|
|
|
|
|
|
|
def set_user_data(self, data):
|
|
|
|
|
self.user_data = data
|
|
|
|
|
|
|
|
|
|
def create_update_account(self, user):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
def authenticate(self):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
def sanitize_email(self, email):
|
|
|
|
|
# Check if email is present
|
|
|
|
|
if not email:
|
2026-03-03 14:05:34 +00:00
|
|
|
self.logger.error("Email is not present")
|
2024-06-05 08:50:57 +00:00
|
|
|
raise AuthenticationException(
|
|
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
|
|
|
|
error_message="INVALID_EMAIL",
|
|
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Sanitize email
|
|
|
|
|
email = str(email).lower().strip()
|
|
|
|
|
|
|
|
|
|
# validate email
|
|
|
|
|
try:
|
|
|
|
|
validate_email(email)
|
|
|
|
|
except ValidationError:
|
2026-07-29 14:58:09 +00:00
|
|
|
self.logger.warning("Email is not valid")
|
2024-06-05 08:50:57 +00:00
|
|
|
raise AuthenticationException(
|
|
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
|
|
|
|
error_message="INVALID_EMAIL",
|
|
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
# Return email
|
|
|
|
|
return email
|
|
|
|
|
|
|
|
|
|
def validate_password(self, email):
|
|
|
|
|
"""Validate password strength"""
|
|
|
|
|
results = zxcvbn(self.code)
|
|
|
|
|
if results["score"] < 3:
|
2026-03-03 14:05:34 +00:00
|
|
|
self.logger.warning("Password is not strong enough")
|
2024-06-05 08:50:57 +00:00
|
|
|
raise AuthenticationException(
|
2026-02-13 13:21:33 +00:00
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
|
|
|
|
error_message="PASSWORD_TOO_WEAK",
|
2024-06-05 08:50:57 +00:00
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def __check_signup(self, email):
|
|
|
|
|
"""Check if sign up is enabled or not and raise exception if not enabled"""
|
|
|
|
|
|
|
|
|
|
# Get configuration value
|
[WEB-5657] feat: add synchronization configuration for multiple providers in authentication adapter (#8336)
* feat: add sync functionality for OAuth providers
- Implemented `check_sync_enabled` method to verify if sync is enabled for Google, GitHub, GitLab, and Gitea.
- Added `sync_user_data` method to update user details, including first name, last name, display name, and avatar.
- Updated configuration variables to include sync options for each provider.
- Integrated sync check into the login/signup process.
* feat: add sync toggle for OAuth providers in configuration forms
* fix: remove default value for sync options in OAuth configuration forms
* chore: delete old avatar and upload a new one
* chore: update class method
* chore: add email nullable
* refactor: streamline sync check for multiple providers and improve avatar deletion logic
* fix: ensure ENABLE_SYNC configurations default to "0" for Gitea, Github, Gitlab, and Google forms
* fix: simplify toggle switch value handling in ControllerSwitch component
---------
Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-12-22 06:53:39 +00:00
|
|
|
(ENABLE_SIGNUP,) = get_configuration_value([
|
|
|
|
|
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
|
|
|
|
|
])
|
2024-06-05 08:50:57 +00:00
|
|
|
|
|
|
|
|
# Check if sign up is disabled and invite is present or not
|
2025-09-29 13:45:32 +00:00
|
|
|
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
|
2026-03-03 14:05:34 +00:00
|
|
|
self.logger.warning("Sign up is disabled and invite is not present")
|
2024-06-05 08:50:57 +00:00
|
|
|
# Raise exception
|
|
|
|
|
raise AuthenticationException(
|
|
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
|
|
|
|
error_message="SIGNUP_DISABLED",
|
|
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
2025-12-09 10:18:27 +00:00
|
|
|
def get_avatar_download_headers(self):
|
|
|
|
|
return {}
|
|
|
|
|
|
[WEB-5657] feat: add synchronization configuration for multiple providers in authentication adapter (#8336)
* feat: add sync functionality for OAuth providers
- Implemented `check_sync_enabled` method to verify if sync is enabled for Google, GitHub, GitLab, and Gitea.
- Added `sync_user_data` method to update user details, including first name, last name, display name, and avatar.
- Updated configuration variables to include sync options for each provider.
- Integrated sync check into the login/signup process.
* feat: add sync toggle for OAuth providers in configuration forms
* fix: remove default value for sync options in OAuth configuration forms
* chore: delete old avatar and upload a new one
* chore: update class method
* chore: add email nullable
* refactor: streamline sync check for multiple providers and improve avatar deletion logic
* fix: ensure ENABLE_SYNC configurations default to "0" for Gitea, Github, Gitlab, and Google forms
* fix: simplify toggle switch value handling in ControllerSwitch component
---------
Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-12-22 06:53:39 +00:00
|
|
|
def check_sync_enabled(self):
|
|
|
|
|
"""Check if sync is enabled for the provider"""
|
|
|
|
|
provider_config_map = {
|
|
|
|
|
"google": "ENABLE_GOOGLE_SYNC",
|
|
|
|
|
"github": "ENABLE_GITHUB_SYNC",
|
|
|
|
|
"gitlab": "ENABLE_GITLAB_SYNC",
|
|
|
|
|
"gitea": "ENABLE_GITEA_SYNC",
|
|
|
|
|
}
|
|
|
|
|
config_key = provider_config_map.get(self.provider)
|
|
|
|
|
if config_key:
|
|
|
|
|
(enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}])
|
|
|
|
|
return enabled == "1"
|
|
|
|
|
return False
|
|
|
|
|
|
2025-12-09 10:18:27 +00:00
|
|
|
def download_and_upload_avatar(self, avatar_url, user):
|
|
|
|
|
"""
|
|
|
|
|
Downloads avatar from OAuth provider and uploads to our storage.
|
|
|
|
|
Returns the uploaded file path or None if failed.
|
|
|
|
|
"""
|
|
|
|
|
if not avatar_url:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
headers = self.get_avatar_download_headers()
|
fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
2026-05-30 18:42:23 +00:00
|
|
|
# Download the avatar image over an SSRF-safe client: the avatar URL
|
|
|
|
|
# comes from the OAuth provider's (attacker-influenceable) profile
|
|
|
|
|
# data, so it must not be allowed to reach internal addresses. The
|
|
|
|
|
# connection is pinned to the validated IP (defeats DNS rebinding)
|
|
|
|
|
# and every redirect hop is re-validated, so a public URL cannot
|
|
|
|
|
# bounce the fetch to an internal target — GHSA-cv9p-325g-wmv5 /
|
|
|
|
|
# GHSA-hx79-5pj5-qh42 (avatar hop).
|
|
|
|
|
# stream=True so the body is read incrementally and the size cap
|
|
|
|
|
# below actually bounds memory (without it, requests buffers the
|
|
|
|
|
# whole body before any check runs).
|
|
|
|
|
response, _ = pinned_fetch_following_redirects(
|
|
|
|
|
"GET", avatar_url, headers=headers, timeout=10, max_redirects=5, stream=True
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
# Check content length before downloading
|
|
|
|
|
content_length = response.headers.get("Content-Length")
|
|
|
|
|
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
|
|
|
|
|
if content_length and int(content_length) > max_size:
|
2025-12-09 10:18:27 +00:00
|
|
|
return None
|
fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
2026-05-30 18:42:23 +00:00
|
|
|
|
|
|
|
|
# Get content type and determine file extension
|
|
|
|
|
content_type = response.headers.get("Content-Type", "image/jpeg")
|
|
|
|
|
extension_map = {
|
|
|
|
|
"image/jpeg": "jpg",
|
|
|
|
|
"image/jpg": "jpg",
|
|
|
|
|
"image/png": "png",
|
|
|
|
|
"image/gif": "gif",
|
|
|
|
|
"image/webp": "webp",
|
|
|
|
|
}
|
|
|
|
|
extension = extension_map.get(content_type)
|
|
|
|
|
|
|
|
|
|
if not extension:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Download with size limit
|
|
|
|
|
chunks = []
|
|
|
|
|
total_size = 0
|
|
|
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
|
|
|
total_size += len(chunk)
|
|
|
|
|
if total_size > max_size:
|
|
|
|
|
return None
|
|
|
|
|
chunks.append(chunk)
|
|
|
|
|
content = b"".join(chunks)
|
|
|
|
|
file_size = len(content)
|
|
|
|
|
finally:
|
|
|
|
|
response.close()
|
2025-12-09 10:18:27 +00:00
|
|
|
|
|
|
|
|
# Generate unique filename
|
|
|
|
|
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
|
|
|
|
|
|
|
|
|
|
storage = S3Storage(request=self.request)
|
|
|
|
|
|
fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
2026-05-30 18:42:23 +00:00
|
|
|
# Create file-like object from the size-bounded buffer
|
|
|
|
|
file_obj = BytesIO(content)
|
2025-12-09 10:18:27 +00:00
|
|
|
file_obj.seek(0)
|
|
|
|
|
|
|
|
|
|
# Upload using boto3 directly
|
|
|
|
|
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
|
|
|
|
|
if not upload_success:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Get storage metadata
|
|
|
|
|
storage_metadata = storage.get_object_metadata(object_name=filename)
|
|
|
|
|
|
|
|
|
|
# Create FileAsset record
|
|
|
|
|
file_asset = FileAsset.objects.create(
|
|
|
|
|
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
|
|
|
|
|
asset=filename,
|
|
|
|
|
size=file_size,
|
|
|
|
|
user=user,
|
|
|
|
|
created_by=user,
|
|
|
|
|
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
|
|
|
|
|
is_uploaded=True,
|
|
|
|
|
storage_metadata=storage_metadata,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return file_asset
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log_exception(e)
|
|
|
|
|
# Return None if upload fails, so original URL can be used as fallback
|
|
|
|
|
return None
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
def save_user_data(self, user):
|
|
|
|
|
# Update user details
|
|
|
|
|
user.last_login_medium = self.provider
|
|
|
|
|
user.last_active = timezone.now()
|
|
|
|
|
user.last_login_time = timezone.now()
|
2025-04-02 17:39:27 +00:00
|
|
|
user.last_login_ip = get_client_ip(request=self.request)
|
2024-06-05 08:50:57 +00:00
|
|
|
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
|
|
|
|
user.token_updated_at = timezone.now()
|
2026-06-24 09:10:24 +00:00
|
|
|
# Activate provisioned accounts that have never been deactivated.
|
|
|
|
|
# Explicitly-deactivated accounts are rejected earlier in
|
|
|
|
|
# complete_login_or_signup() before this method is ever reached.
|
|
|
|
|
# Save first so activation is persisted before the email side-effect fires.
|
|
|
|
|
was_inactive = not user.is_active
|
|
|
|
|
user.is_active = True
|
2024-06-05 08:50:57 +00:00
|
|
|
user.save()
|
2026-06-24 09:10:24 +00:00
|
|
|
if was_inactive:
|
|
|
|
|
try:
|
|
|
|
|
user_activation_email.delay(base_host(request=self.request), user.id)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log_exception(e)
|
2024-06-05 08:50:57 +00:00
|
|
|
return user
|
|
|
|
|
|
[WEB-5657] feat: add synchronization configuration for multiple providers in authentication adapter (#8336)
* feat: add sync functionality for OAuth providers
- Implemented `check_sync_enabled` method to verify if sync is enabled for Google, GitHub, GitLab, and Gitea.
- Added `sync_user_data` method to update user details, including first name, last name, display name, and avatar.
- Updated configuration variables to include sync options for each provider.
- Integrated sync check into the login/signup process.
* feat: add sync toggle for OAuth providers in configuration forms
* fix: remove default value for sync options in OAuth configuration forms
* chore: delete old avatar and upload a new one
* chore: update class method
* chore: add email nullable
* refactor: streamline sync check for multiple providers and improve avatar deletion logic
* fix: ensure ENABLE_SYNC configurations default to "0" for Gitea, Github, Gitlab, and Google forms
* fix: simplify toggle switch value handling in ControllerSwitch component
---------
Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-12-22 06:53:39 +00:00
|
|
|
def delete_old_avatar(self, user):
|
|
|
|
|
"""Delete the old avatar if it exists"""
|
|
|
|
|
try:
|
|
|
|
|
if user.avatar_asset:
|
|
|
|
|
asset = FileAsset.objects.get(pk=user.avatar_asset_id)
|
|
|
|
|
storage = S3Storage(request=self.request)
|
|
|
|
|
storage.delete_files(object_names=[asset.asset.name])
|
|
|
|
|
|
|
|
|
|
# Delete the user avatar
|
|
|
|
|
asset.delete()
|
|
|
|
|
user.avatar_asset = None
|
|
|
|
|
user.avatar = ""
|
|
|
|
|
user.save()
|
|
|
|
|
return
|
|
|
|
|
except FileAsset.DoesNotExist:
|
|
|
|
|
pass
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log_exception(e)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def sync_user_data(self, user):
|
|
|
|
|
# Update user details
|
|
|
|
|
first_name = self.user_data.get("user", {}).get("first_name", "")
|
|
|
|
|
last_name = self.user_data.get("user", {}).get("last_name", "")
|
|
|
|
|
user.first_name = first_name if first_name else ""
|
|
|
|
|
user.last_name = last_name if last_name else ""
|
|
|
|
|
|
|
|
|
|
# Get email
|
|
|
|
|
email = self.user_data.get("email")
|
|
|
|
|
|
|
|
|
|
# Get display name
|
|
|
|
|
display_name = self.user_data.get("user", {}).get("display_name")
|
|
|
|
|
# If display name is not provided, generate a random display name
|
|
|
|
|
if not display_name:
|
|
|
|
|
display_name = User.get_display_name(email)
|
|
|
|
|
|
|
|
|
|
# Set display name
|
|
|
|
|
user.display_name = display_name
|
|
|
|
|
|
|
|
|
|
# Download and upload avatar only if the avatar is different from the one in the storage
|
|
|
|
|
avatar = self.user_data.get("user", {}).get("avatar", "")
|
|
|
|
|
# Delete the old avatar if it exists
|
|
|
|
|
self.delete_old_avatar(user=user)
|
|
|
|
|
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
|
|
|
|
|
if avatar_asset:
|
|
|
|
|
user.avatar_asset = avatar_asset
|
|
|
|
|
# If avatar upload fails, set the avatar to the original URL
|
|
|
|
|
else:
|
|
|
|
|
user.avatar = avatar
|
|
|
|
|
|
|
|
|
|
user.save()
|
|
|
|
|
return user
|
|
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
def complete_login_or_signup(self):
|
2024-06-05 08:50:57 +00:00
|
|
|
# Get email
|
2024-05-08 17:31:20 +00:00
|
|
|
email = self.user_data.get("email")
|
2024-06-05 08:50:57 +00:00
|
|
|
|
|
|
|
|
# Sanitize email
|
|
|
|
|
email = self.sanitize_email(email)
|
|
|
|
|
|
|
|
|
|
# Check if the user is present
|
2024-05-08 17:31:20 +00:00
|
|
|
user = User.objects.filter(email=email).first()
|
2026-06-23 12:40:40 +00:00
|
|
|
|
2026-06-24 09:10:24 +00:00
|
|
|
# Reject explicitly-deactivated accounts (GHSA-rmmf-rj2q-3rrg).
|
|
|
|
|
# The deactivation endpoint always sets last_logout_time, so using it
|
|
|
|
|
# as the discriminator is more reliable than last_login_time: a
|
|
|
|
|
# provisioned account that was never deactivated has last_logout_time=None
|
|
|
|
|
# and is allowed through for its first login; an account deactivated via
|
|
|
|
|
# the API has last_logout_time set and is blocked regardless of whether
|
|
|
|
|
# it had previously logged in.
|
|
|
|
|
if user and not user.is_active and user.last_logout_time is not None:
|
2026-06-23 12:40:40 +00:00
|
|
|
raise AuthenticationException(
|
|
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
|
|
|
|
|
error_message="USER_ACCOUNT_DEACTIVATED",
|
|
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
|
fix(security): block bot user logins (#9368)
Bot service accounts (User.is_bot=True, e.g. the WORKSPACE_SEED bot) are
internal identities meant to act only through API tokens. Nothing stopped
one from being driven through the interactive login flow if its email was
known, letting a human assume a service identity.
Reject bot accounts at the shared login chokepoint,
Adapter.complete_login_or_signup(), right beside the existing
deactivated-account check. This covers every interactive provider in one
place: email/password, magic code, and all OAuth providers (Google, GitHub,
GitLab, Gitea) across both the app and space surfaces. Bot API-token access
is left untouched, since that is how bots are meant to operate.
Also add a defense-in-depth is_bot guard to InstanceAdminSignInEndpoint,
which mints its own admin session outside the chokepoint (a bot is never an
InstanceAdmin today, so this is not currently reachable, but it closes the
path regardless).
Surface the rejection with a new dedicated error code
BOT_USER_LOGIN_FORBIDDEN (5017), plumbed into the app and space frontend
error helpers as well as the shared @plane/constants and @plane/utils
packages (message map + banner-alert list) so any consumer of the shared
auth-error handler renders it correctly. The admin path reuses the existing
ADMIN_AUTHENTICATION_FAILED code so it discloses no bot-specific error.
Add contract regression tests: a bot blocked via password and via magic
code, a bot blocked at the admin sign-in endpoint, and a non-bot control
that still logs in.
2026-07-07 20:21:37 +00:00
|
|
|
# Reject bot service accounts (BOT_USER_LOGIN_FORBIDDEN). Bots (is_bot=True,
|
|
|
|
|
# e.g. the WORKSPACE_SEED bot) are internal identities that act only through
|
|
|
|
|
# API tokens; they must never be assumable via the interactive login/signup
|
|
|
|
|
# flow (email/password, magic code, or any OAuth provider). A brand-new
|
|
|
|
|
# signup can never be a bot — bots are provisioned internally, never through
|
|
|
|
|
# this path — so guarding on an existing `user` record is sufficient.
|
|
|
|
|
if user and user.is_bot:
|
|
|
|
|
raise AuthenticationException(
|
|
|
|
|
error_code=AUTHENTICATION_ERROR_CODES["BOT_USER_LOGIN_FORBIDDEN"],
|
|
|
|
|
error_message="BOT_USER_LOGIN_FORBIDDEN",
|
|
|
|
|
payload={"email": email},
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-24 09:10:24 +00:00
|
|
|
# True = new user (signup), False = returning user (login)
|
|
|
|
|
is_signup = not bool(user)
|
2024-06-05 08:50:57 +00:00
|
|
|
# If user is not present, create a new user
|
2024-05-08 17:31:20 +00:00
|
|
|
if not user:
|
|
|
|
|
# New user
|
2024-06-05 08:50:57 +00:00
|
|
|
self.__check_signup(email)
|
|
|
|
|
|
|
|
|
|
# Initialize user
|
2024-05-08 17:31:20 +00:00
|
|
|
user = User(email=email, username=uuid.uuid4().hex)
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Check if password is autoset
|
2024-05-08 17:31:20 +00:00
|
|
|
if self.user_data.get("user").get("is_password_autoset"):
|
|
|
|
|
user.set_password(uuid.uuid4().hex)
|
|
|
|
|
user.is_password_autoset = True
|
|
|
|
|
user.is_email_verified = True
|
2024-06-05 08:50:57 +00:00
|
|
|
|
|
|
|
|
# Validate password
|
2024-05-08 17:31:20 +00:00
|
|
|
else:
|
|
|
|
|
# Validate password
|
2024-06-05 08:50:57 +00:00
|
|
|
self.validate_password(email)
|
|
|
|
|
# Set password
|
2024-05-08 17:31:20 +00:00
|
|
|
user.set_password(self.code)
|
|
|
|
|
user.is_password_autoset = False
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Set user details
|
2024-05-08 17:31:20 +00:00
|
|
|
first_name = self.user_data.get("user", {}).get("first_name", "")
|
|
|
|
|
last_name = self.user_data.get("user", {}).get("last_name", "")
|
|
|
|
|
user.first_name = first_name if first_name else ""
|
|
|
|
|
user.last_name = last_name if last_name else ""
|
2025-12-09 10:18:27 +00:00
|
|
|
|
2024-05-08 17:31:20 +00:00
|
|
|
user.save()
|
2024-06-05 08:50:57 +00:00
|
|
|
|
2025-12-09 10:18:27 +00:00
|
|
|
# Download and upload avatar
|
|
|
|
|
avatar = self.user_data.get("user", {}).get("avatar", "")
|
|
|
|
|
if avatar:
|
|
|
|
|
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
|
|
|
|
|
if avatar_asset:
|
|
|
|
|
user.avatar_asset = avatar_asset
|
[WEB-5657] feat: add synchronization configuration for multiple providers in authentication adapter (#8336)
* feat: add sync functionality for OAuth providers
- Implemented `check_sync_enabled` method to verify if sync is enabled for Google, GitHub, GitLab, and Gitea.
- Added `sync_user_data` method to update user details, including first name, last name, display name, and avatar.
- Updated configuration variables to include sync options for each provider.
- Integrated sync check into the login/signup process.
* feat: add sync toggle for OAuth providers in configuration forms
* fix: remove default value for sync options in OAuth configuration forms
* chore: delete old avatar and upload a new one
* chore: update class method
* chore: add email nullable
* refactor: streamline sync check for multiple providers and improve avatar deletion logic
* fix: ensure ENABLE_SYNC configurations default to "0" for Gitea, Github, Gitlab, and Google forms
* fix: simplify toggle switch value handling in ControllerSwitch component
---------
Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-12-22 06:53:39 +00:00
|
|
|
user.avatar = avatar
|
2025-12-09 10:18:27 +00:00
|
|
|
# If avatar upload fails, set the avatar to the original URL
|
|
|
|
|
else:
|
|
|
|
|
user.avatar = avatar
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Create profile
|
2024-05-08 17:31:20 +00:00
|
|
|
Profile.objects.create(user=user)
|
|
|
|
|
|
[WEB-5657] feat: add synchronization configuration for multiple providers in authentication adapter (#8336)
* feat: add sync functionality for OAuth providers
- Implemented `check_sync_enabled` method to verify if sync is enabled for Google, GitHub, GitLab, and Gitea.
- Added `sync_user_data` method to update user details, including first name, last name, display name, and avatar.
- Updated configuration variables to include sync options for each provider.
- Integrated sync check into the login/signup process.
* feat: add sync toggle for OAuth providers in configuration forms
* fix: remove default value for sync options in OAuth configuration forms
* chore: delete old avatar and upload a new one
* chore: update class method
* chore: add email nullable
* refactor: streamline sync check for multiple providers and improve avatar deletion logic
* fix: ensure ENABLE_SYNC configurations default to "0" for Gitea, Github, Gitlab, and Google forms
* fix: simplify toggle switch value handling in ControllerSwitch component
---------
Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-12-22 06:53:39 +00:00
|
|
|
# Check if IDP sync is enabled and user is not signing up
|
|
|
|
|
if self.check_sync_enabled() and not is_signup:
|
|
|
|
|
user = self.sync_user_data(user=user)
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Save user data
|
|
|
|
|
user = self.save_user_data(user=user)
|
2024-05-08 17:31:20 +00:00
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Call callback if present
|
2024-05-17 09:57:34 +00:00
|
|
|
if self.callback:
|
2024-11-21 12:12:44 +00:00
|
|
|
self.callback(user, is_signup, self.request)
|
2024-05-17 09:57:34 +00:00
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Create or update account if token data is present
|
2024-05-08 17:31:20 +00:00
|
|
|
if self.token_data:
|
|
|
|
|
self.create_update_account(user=user)
|
|
|
|
|
|
2024-06-05 08:50:57 +00:00
|
|
|
# Return user
|
2024-05-08 17:31:20 +00:00
|
|
|
return user
|