Skip to content

Commit ebbee53

Browse files
authored
Merge pull request #16 from adab-tech/claude/globalopportunities-deploy-yukhn1
Harden scraper against SSRF, add search autocomplete and email copy button
2 parents 59479d6 + b9c14ad commit ebbee53

8 files changed

Lines changed: 496 additions & 18 deletions

File tree

backend/app/routes/opportunities.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77

88
from app.database import get_db
99
from app.models import Opportunity
10-
from app.schemas import OpportunityResponse, PaginatedOpportunities, StatsResponse
10+
from app.schemas import (
11+
OpportunityResponse,
12+
PaginatedOpportunities,
13+
StatsResponse,
14+
SuggestionsResponse,
15+
)
1116

1217
router = APIRouter(prefix="/opportunities", tags=["Opportunities"])
1318

@@ -88,6 +93,28 @@ def list_opportunities(
8893
)
8994

9095

96+
@router.get("/suggest", response_model=SuggestionsResponse)
97+
def suggest(
98+
q: str = Query(..., min_length=2, max_length=100),
99+
limit: int = Query(8, ge=1, le=20),
100+
db: Session = Depends(get_db),
101+
):
102+
"""Distinct titles matching the query, for header search autocomplete."""
103+
term = f"%{q}%"
104+
# Postgres requires ORDER BY expressions to appear in the SELECT list
105+
# for SELECT DISTINCT, so this orders alphabetically rather than by
106+
# recency (fine for an autocomplete list).
107+
titles = (
108+
_public_visible(db.query(Opportunity.title).filter(Opportunity.is_active.is_(True)))
109+
.filter(Opportunity.title.ilike(term))
110+
.distinct()
111+
.order_by(Opportunity.title.asc())
112+
.limit(limit)
113+
.all()
114+
)
115+
return SuggestionsResponse(suggestions=[t[0] for t in titles])
116+
117+
91118
@router.get("/stats", response_model=StatsResponse)
92119
def get_stats(db: Session = Depends(get_db)):
93120
counts = dict(

backend/app/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ class PaginatedOpportunities(BaseModel):
3838
data: list[OpportunityResponse]
3939

4040

41+
class SuggestionsResponse(BaseModel):
42+
suggestions: list[str]
43+
44+
4145
class ScrapeRequest(BaseModel):
4246
opportunity_types: list[str] | None = Field(
4347
default_factory=lambda: ["scholarship", "fellowship", "grant", "job"]

backend/app/scrapers/base_scraper.py

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
rate limiting, and common text-extraction helpers.
44
"""
55

6+
import ipaddress
67
import logging
78
import random
9+
import socket
810
import time
9-
from urllib.parse import urlparse
11+
from urllib.parse import urljoin, urlparse
1012
from urllib.robotparser import RobotFileParser
1113

1214
import requests
@@ -19,6 +21,40 @@
1921

2022
logger = logging.getLogger(__name__)
2123

24+
_MAX_REDIRECTS = 5
25+
26+
27+
def resolves_to_public_address(url: str) -> bool:
28+
"""False if the URL's host is missing, unresolvable, or resolves to
29+
any private/loopback/link-local/reserved/multicast address.
30+
31+
Discovery URLs come from search results and RSS feeds — content
32+
this app doesn't control. Without this check, a malicious or
33+
compromised third-party page could redirect the scraper's request
34+
into internal infrastructure (e.g. a cloud metadata endpoint) since
35+
fetch_page previously followed redirects with no destination check
36+
at all. Applied both to the initial URL and to every redirect hop.
37+
"""
38+
host = urlparse(url).hostname
39+
if not host:
40+
return False
41+
try:
42+
infos = socket.getaddrinfo(host, None)
43+
except socket.gaierror:
44+
return False
45+
for info in infos:
46+
ip = ipaddress.ip_address(info[4][0])
47+
if (
48+
not ip.is_global
49+
or ip.is_private
50+
or ip.is_loopback
51+
or ip.is_link_local
52+
or ip.is_multicast
53+
or ip.is_reserved
54+
):
55+
return False
56+
return True
57+
2258

2359
class BaseScraper:
2460
def __init__(self):
@@ -62,6 +98,10 @@ def _can_fetch(self, url: str) -> bool:
6298

6399
def fetch_page(self, url: str, delay: bool = True) -> BeautifulSoup | None:
64100
"""Fetch a URL and return a parsed BeautifulSoup tree, or None on failure."""
101+
if not resolves_to_public_address(url):
102+
logger.warning(f"Skipping {url} — does not resolve to a public address")
103+
return None
104+
65105
if not self._can_fetch(url):
66106
logger.info(f"Skipping {url} — blocked by robots.txt")
67107
return None
@@ -71,18 +111,35 @@ def fetch_page(self, url: str, delay: bool = True) -> BeautifulSoup | None:
71111

72112
for attempt in range(settings.MAX_RETRIES):
73113
try:
74-
response = self.session.get(
75-
url,
76-
headers=self._get_headers(),
77-
timeout=settings.REQUEST_TIMEOUT,
78-
allow_redirects=True,
79-
)
80-
response.raise_for_status()
81-
content_type = response.headers.get("Content-Type", "")
82-
if content_type and "html" not in content_type and "xml" not in content_type:
83-
logger.info(f"Skipping {url} — non-HTML content ({content_type})")
114+
current_url = url
115+
for _ in range(_MAX_REDIRECTS + 1):
116+
response = self.session.get(
117+
current_url,
118+
headers=self._get_headers(),
119+
timeout=settings.REQUEST_TIMEOUT,
120+
allow_redirects=False,
121+
)
122+
if response.is_redirect or response.is_permanent_redirect:
123+
location = response.headers.get("Location")
124+
if not location:
125+
return None
126+
next_url = urljoin(current_url, location)
127+
if not resolves_to_public_address(next_url):
128+
logger.warning(
129+
f"Skipping redirect from {current_url} to {next_url} — not a public address"
130+
)
131+
return None
132+
current_url = next_url
133+
continue
134+
response.raise_for_status()
135+
content_type = response.headers.get("Content-Type", "")
136+
if content_type and "html" not in content_type and "xml" not in content_type:
137+
logger.info(f"Skipping {url} — non-HTML content ({content_type})")
138+
return None
139+
return BeautifulSoup(response.text, "lxml")
140+
else:
141+
logger.warning(f"Skipping {url} — too many redirects")
84142
return None
85-
return BeautifulSoup(response.text, "lxml")
86143
except requests.RequestException as exc:
87144
logger.warning(f"Attempt {attempt + 1}/{settings.MAX_RETRIES} failed for {url}: {exc}")
88145
if attempt < settings.MAX_RETRIES - 1:

backend/tests/test_base_scraper.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import socket
2+
from unittest.mock import MagicMock, patch
3+
4+
from app.scrapers.base_scraper import BaseScraper, resolves_to_public_address
5+
6+
7+
class TestResolvesToPublicAddress:
8+
def test_no_hostname_is_rejected(self):
9+
assert resolves_to_public_address("not-a-url") is False
10+
11+
def test_unresolvable_host_is_rejected(self):
12+
with patch("app.scrapers.base_scraper.socket.getaddrinfo", side_effect=socket.gaierror):
13+
assert resolves_to_public_address("https://nonexistent.invalid/") is False
14+
15+
def test_loopback_address_is_rejected(self):
16+
with patch(
17+
"app.scrapers.base_scraper.socket.getaddrinfo",
18+
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443))],
19+
):
20+
assert resolves_to_public_address("https://sneaky.example/") is False
21+
22+
def test_private_address_is_rejected(self):
23+
with patch(
24+
"app.scrapers.base_scraper.socket.getaddrinfo",
25+
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 443))],
26+
):
27+
assert resolves_to_public_address("https://sneaky.example/") is False
28+
29+
def test_link_local_metadata_address_is_rejected(self):
30+
# 169.254.169.254 is the AWS/GCP/Azure cloud metadata endpoint —
31+
# the canonical SSRF target this check exists to block.
32+
with patch(
33+
"app.scrapers.base_scraper.socket.getaddrinfo",
34+
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 80))],
35+
):
36+
assert resolves_to_public_address("http://sneaky.example/") is False
37+
38+
def test_public_address_is_accepted(self):
39+
with patch(
40+
"app.scrapers.base_scraper.socket.getaddrinfo",
41+
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))],
42+
):
43+
assert resolves_to_public_address("https://example.org/") is True
44+
45+
46+
def _redirect_response(location):
47+
resp = MagicMock()
48+
resp.is_redirect = True
49+
resp.is_permanent_redirect = False
50+
resp.headers = {"Location": location}
51+
return resp
52+
53+
54+
def _ok_response(text="<html><body>ok</body></html>", content_type="text/html"):
55+
resp = MagicMock()
56+
resp.is_redirect = False
57+
resp.is_permanent_redirect = False
58+
resp.raise_for_status.return_value = None
59+
resp.headers = {"Content-Type": content_type}
60+
resp.text = text
61+
return resp
62+
63+
64+
class TestFetchPageSSRF:
65+
def test_initial_url_not_public_is_skipped(self):
66+
scraper = BaseScraper()
67+
with patch("app.scrapers.base_scraper.resolves_to_public_address", return_value=False):
68+
with patch.object(scraper.session, "get") as mock_get:
69+
result = scraper.fetch_page("http://169.254.169.254/latest/meta-data/", delay=False)
70+
assert result is None
71+
mock_get.assert_not_called()
72+
73+
def test_redirect_to_private_address_is_rejected(self):
74+
scraper = BaseScraper()
75+
with patch.object(scraper, "_can_fetch", return_value=True):
76+
with patch(
77+
"app.scrapers.base_scraper.resolves_to_public_address",
78+
side_effect=[True, False],
79+
):
80+
with patch.object(
81+
scraper.session,
82+
"get",
83+
return_value=_redirect_response("http://169.254.169.254/"),
84+
):
85+
result = scraper.fetch_page("https://example.org/redirector", delay=False)
86+
assert result is None
87+
88+
def test_normal_page_is_fetched(self):
89+
scraper = BaseScraper()
90+
with patch("app.scrapers.base_scraper.resolves_to_public_address", return_value=True):
91+
with patch.object(scraper, "_can_fetch", return_value=True):
92+
with patch.object(scraper.session, "get", return_value=_ok_response()):
93+
result = scraper.fetch_page("https://example.org/page", delay=False)
94+
assert result is not None
95+
assert result.body.get_text() == "ok"
96+
97+
def test_too_many_redirects_gives_up(self):
98+
scraper = BaseScraper()
99+
with patch.object(scraper, "_can_fetch", return_value=True):
100+
with patch("app.scrapers.base_scraper.resolves_to_public_address", return_value=True):
101+
with patch.object(
102+
scraper.session,
103+
"get",
104+
return_value=_redirect_response("https://example.org/next"),
105+
):
106+
result = scraper.fetch_page("https://example.org/loop", delay=False)
107+
assert result is None

backend/tests/test_suggest.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Regression tests for the /opportunities/suggest autocomplete endpoint."""
2+
3+
from datetime import date, timedelta
4+
5+
from fastapi.testclient import TestClient
6+
7+
from app.database import SessionLocal
8+
from app.main import app
9+
from app.models import Opportunity
10+
11+
client = TestClient(app)
12+
13+
_TEST_URL_PREFIX = "https://example.org/suggest-test-"
14+
15+
16+
def _cleanup(db):
17+
db.query(Opportunity).filter(Opportunity.url.like(f"{_TEST_URL_PREFIX}%")).delete(
18+
synchronize_session=False
19+
)
20+
db.commit()
21+
22+
23+
def _make(db, suffix: str, **overrides) -> Opportunity:
24+
defaults = dict(
25+
title=f"Suggest Fellowship {suffix}",
26+
opportunity_type="fellowship",
27+
url=f"{_TEST_URL_PREFIX}{suffix}",
28+
source_name="Test Source",
29+
is_active=True,
30+
review_status="approved",
31+
)
32+
defaults.update(overrides)
33+
opp = Opportunity(**defaults)
34+
db.add(opp)
35+
db.commit()
36+
db.refresh(opp)
37+
return opp
38+
39+
40+
class TestSuggest:
41+
def setup_method(self):
42+
db = SessionLocal()
43+
_cleanup(db)
44+
db.close()
45+
46+
def teardown_method(self):
47+
db = SessionLocal()
48+
_cleanup(db)
49+
db.close()
50+
51+
def test_query_too_short_is_rejected(self):
52+
response = client.get("/api/v1/opportunities/suggest", params={"q": "a"})
53+
assert response.status_code == 422
54+
55+
def test_matches_are_returned(self):
56+
db = SessionLocal()
57+
_make(db, "1")
58+
db.close()
59+
60+
response = client.get("/api/v1/opportunities/suggest", params={"q": "Suggest Fellowship"})
61+
assert response.status_code == 200
62+
assert "Suggest Fellowship 1" in response.json()["suggestions"]
63+
64+
def test_pending_opportunities_are_excluded(self):
65+
db = SessionLocal()
66+
_make(db, "2", review_status="pending")
67+
db.close()
68+
69+
response = client.get("/api/v1/opportunities/suggest", params={"q": "Suggest Fellowship"})
70+
assert response.json()["suggestions"] == []
71+
72+
def test_expired_opportunities_are_excluded(self):
73+
db = SessionLocal()
74+
_make(db, "3", deadline_at=date.today() - timedelta(days=1))
75+
db.close()
76+
77+
response = client.get("/api/v1/opportunities/suggest", params={"q": "Suggest Fellowship"})
78+
assert response.json()["suggestions"] == []
79+
80+
def test_limit_is_respected(self):
81+
db = SessionLocal()
82+
for i in range(5):
83+
_make(db, f"limit-{i}")
84+
db.close()
85+
86+
response = client.get(
87+
"/api/v1/opportunities/suggest",
88+
params={"q": "Suggest Fellowship", "limit": 2},
89+
)
90+
assert len(response.json()["suggestions"]) <= 2

0 commit comments

Comments
 (0)