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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions api/serializers/feeds.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import hashlib
import logging
from collections.abc import Mapping
from typing import cast

from django.conf import settings
from django.core import signing
from rest_framework import serializers

from api.serializers.common import SensorSerializer, TagSerializer
from api.serializers.utils import PresenceFlagField, feed_type_as_list, get_valid_feed_types
from greedybear.consts import SHARE_TOKEN_MAX_AGE, SHARE_TOKEN_SALT
from api.share_tokens import share_token_salt_for_user
from greedybear.consts import SHARE_TOKEN_MAX_AGE
from greedybear.cronjobs.trending import validate_window_minutes
from greedybear.enums import IpReputation
from greedybear.models import ShareToken
Expand Down Expand Up @@ -304,8 +306,15 @@ def validate(self, attrs: dict) -> dict:
attrs["share_token"] = ShareToken.objects.get(token_hash=token_hash)
except ShareToken.DoesNotExist as exc:
raise serializers.ValidationError("Invalid or expired token") from exc
share_token = attrs["share_token"]
# Use the FK attname to avoid fetching the related user.
owner_id = cast("int", share_token.user_id) # ty: ignore[unresolved-attribute] # Django generates this FK attribute
try:
attrs["feed_params"] = signing.loads(token, salt=SHARE_TOKEN_SALT, max_age=SHARE_TOKEN_MAX_AGE)
attrs["feed_params"] = signing.loads(
token,
salt=share_token_salt_for_user(owner_id),
max_age=SHARE_TOKEN_MAX_AGE,
)
except signing.BadSignature as exc:
raise serializers.ValidationError("Invalid or expired token") from exc
return attrs
Expand Down
8 changes: 8 additions & 0 deletions api/share_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
# See the file 'LICENSE' for copying permission.
from greedybear.consts import SHARE_TOKEN_SALT


def share_token_salt_for_user(user_id: int) -> str:
"""Derive a signing salt scoped to the user who created a share token."""
return f"{SHARE_TOKEN_SALT}:{user_id}"
8 changes: 6 additions & 2 deletions api/views/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import hashlib
import json
from datetime import timedelta
from typing import cast

from certego_saas.ext.pagination import CustomPageNumberPagination
from django.contrib.postgres.aggregates import ArrayAgg
Expand Down Expand Up @@ -42,14 +43,15 @@
TrendingFeedRequestSerializer,
TrendingFeedResponseSerializer,
)
from api.share_tokens import share_token_salt_for_user
from api.throttles import FeedsAdvancedThrottle, FeedsThrottle, SharedFeedRateThrottle
from api.views.utils import (
aggregate_iocs_by_asn,
build_feed_dict,
save_request_source,
stream_ioc_objects,
)
from greedybear.consts import SHARE_TOKEN_SALT, TRENDING_FEEDS_DATA_VERSION_KEY
from greedybear.consts import TRENDING_FEEDS_DATA_VERSION_KEY
from greedybear.cronjobs.repositories import PayloadRepository, TrendingBucketRepository
from greedybear.cronjobs.trending import build_ranked_attackers
from greedybear.models import IOC, ShareToken, ViewType
Expand Down Expand Up @@ -524,7 +526,9 @@ def share(self, request: Request) -> Response:
# so the token format and consume-time replay stay unchanged.
data = request.query_params.dict()
data.pop("reason", None)
token = signing.dumps(data, salt=SHARE_TOKEN_SALT)
# IsAuthenticated guarantees request.user is a persisted user.
user_id = cast("int", request.user.pk)
token = signing.dumps(data, salt=share_token_salt_for_user(user_id))
token_hash = hashlib.sha256(token.encode()).hexdigest()
ShareToken.objects.get_or_create(
token_hash=token_hash,
Expand Down
1 change: 1 addition & 0 deletions greedybear/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
SHARE_TOKEN_SALT = "greedybear-feeds"
SHARE_TOKEN_MAX_AGE = 86400 * 30 # 30 days


# Event injection API
APISOURCE_LOCKED_THRESHOLD = 10

Expand Down
13 changes: 9 additions & 4 deletions tests/api/views/test_feeds_advanced_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.core.cache import cache
from rest_framework.test import APIClient

from api.share_tokens import share_token_salt_for_user
from api.throttles import SharedFeedRateThrottle
from greedybear.models import IOC, AutonomousSystem, Credential, HoneypotPayload, IocType, Sensor, ShareToken, Tag
from tests import CustomTestCase
Expand Down Expand Up @@ -559,8 +560,8 @@ def test_consume_shared_feed_with_end_date_max(self):
response = self.client.get(f"/api/feeds/consume/{token}")
self.assertEqual(response.status_code, 200)

def test_shareable_feed_expired_token(self):
"""Consuming a tampered/expired token returns 400."""
def test_shareable_feed_tampered_token(self):
"""Consuming a tampered token with a matching DB record returns 400."""
data = {
"feed_type": "all",
"attack_type": "all",
Expand All @@ -580,8 +581,12 @@ def test_shareable_feed_expired_token(self):
"start_date": None,
"end_date": None,
}
token = signing.dumps(data, salt="greedybear-feeds")
token = signing.dumps(data, salt=share_token_salt_for_user(self.superuser.pk))
tampered = token + "TAMPERED"
ShareToken.objects.create(
user=self.superuser,
token_hash=hashlib.sha256(tampered.encode()).hexdigest(),
)
self.client.logout()
response = self.client.get(f"/api/feeds/consume/{tampered}")
self.assertEqual(response.status_code, 400)
Expand All @@ -608,7 +613,7 @@ def test_consume_valid_token_without_db_record_is_rejected(self):
"start_date": None,
"end_date": None,
}
token = signing.dumps(data, salt="greedybear-feeds")
token = signing.dumps(data, salt=share_token_salt_for_user(self.superuser.pk))
token_hash = hashlib.sha256(token.encode()).hexdigest()

self.assertFalse(ShareToken.objects.filter(token_hash=token_hash).exists())
Expand Down
53 changes: 52 additions & 1 deletion tests/api/views/test_feeds_share_view.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from unittest.mock import patch

from certego_saas.apps.user.models import User
from rest_framework import status
from rest_framework.test import APIClient

Expand Down Expand Up @@ -41,7 +44,8 @@ def test_share_reason_truncated_at_256_chars(self):
share_token = ShareToken.objects.get(token_hash=token_hash)
self.assertEqual(len(share_token.reason), 256)

def test_share_reason_only_set_on_create(self):
@patch("django.core.signing.time.time", return_value=1_800_000_000)
def test_share_reason_only_set_on_create(self, _mock_time):
"""get_or_create: when the same token already exists, reason is NOT overwritten."""
r1 = self.client.post("/api/feeds/share?reason=first")
self.assertEqual(r1.status_code, 200)
Expand All @@ -60,6 +64,53 @@ def test_share_reason_only_set_on_create(self):
share_token = ShareToken.objects.get(token_hash=token_hash)
self.assertEqual(share_token.reason, "first")

@patch("django.core.signing.time.time", return_value=1_800_000_000)
def test_same_second_shares_are_scoped_to_their_owners(self, _mock_time):
"""Identical feed params from two regular users must produce independently owned tokens."""
first_user = User.objects.create_user(
username="share-owner-a",
email="share-owner-a@example.com",
password="test-password",
)
second_user = User.objects.create_user(
username="share-owner-b",
email="share-owner-b@example.com",
password="test-password",
)
self.assertFalse(first_user.is_staff)
self.assertFalse(second_user.is_staff)

first_client = APIClient()
first_client.force_authenticate(user=first_user)
first_response = first_client.post("/api/feeds/share?asn=11111&reason=first")
self.assertEqual(first_response.status_code, 200)
first_url = first_response.json()["url"]
first_token = first_url.split("/api/feeds/consume/")[1]
first_hash = self._hash_from_url(first_url)

second_client = APIClient()
second_client.force_authenticate(user=second_user)
second_response = second_client.post("/api/feeds/share?asn=11111&reason=second")
self.assertEqual(second_response.status_code, 200)
second_url = second_response.json()["url"]
second_token = second_url.split("/api/feeds/consume/")[1]
second_hash = self._hash_from_url(second_url)

self.assertNotEqual(first_token, second_token)
self.assertEqual(ShareToken.objects.count(), 2)
self.assertEqual(ShareToken.objects.get(token_hash=first_hash).user, first_user)
self.assertEqual(ShareToken.objects.get(token_hash=first_hash).reason, "first")
self.assertEqual(ShareToken.objects.get(token_hash=second_hash).user, second_user)
self.assertEqual(ShareToken.objects.get(token_hash=second_hash).reason, "second")

self.assertEqual(len(first_client.get("/api/feeds/tokens/").json()), 1)
self.assertEqual(len(second_client.get("/api/feeds/tokens/").json()), 1)

self.assertEqual(second_client.post(f"/api/feeds/revoke/{first_token}").status_code, 403)
self.assertEqual(first_client.post(f"/api/feeds/revoke/{first_token}").status_code, 200)
self.assertEqual(first_client.post(f"/api/feeds/revoke/{second_token}").status_code, 403)
self.assertEqual(second_client.post(f"/api/feeds/revoke/{second_token}").status_code, 200)

def test_get_method_not_allowed(self):
"""GET is no longer accepted on the share endpoint; it must be POSTed."""
response = self.client.get("/api/feeds/share")
Expand Down
Loading