-
Notifications
You must be signed in to change notification settings - Fork 0
feat(seo): add bot detection for dynamic og:image tags #3171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | |||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,9 +1,9 @@ | |||||||||||||||||||||||||||||
| """SEO endpoints (sitemap).""" | |||||||||||||||||||||||||||||
| """SEO endpoints (sitemap, bot-optimized pages).""" | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| import html | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| from fastapi import APIRouter, Depends | |||||||||||||||||||||||||||||
| from fastapi.responses import Response | |||||||||||||||||||||||||||||
| from fastapi import APIRouter, Depends, HTTPException | |||||||||||||||||||||||||||||
| from fastapi.responses import HTMLResponse, Response | |||||||||||||||||||||||||||||
| from sqlalchemy.ext.asyncio import AsyncSession | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| from api.cache import cache_key, get_cache, set_cache | |||||||||||||||||||||||||||||
|
|
@@ -14,6 +14,32 @@ | ||||||||||||||||||||||||||||
| router = APIRouter(tags=["seo"]) | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| # Minimal HTML template for social media bots (meta tags are what matters) | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE = """<!DOCTYPE html> | |||||||||||||||||||||||||||||
| <html lang="en"> | |||||||||||||||||||||||||||||
| <head> | |||||||||||||||||||||||||||||
| <meta charset="UTF-8" /> | |||||||||||||||||||||||||||||
| <title>{title}</title> | |||||||||||||||||||||||||||||
| <meta name="description" content="{description}" /> | |||||||||||||||||||||||||||||
| <meta property="og:title" content="{title}" /> | |||||||||||||||||||||||||||||
| <meta property="og:description" content="{description}" /> | |||||||||||||||||||||||||||||
| <meta property="og:image" content="{image}" /> | |||||||||||||||||||||||||||||
| <meta property="og:url" content="{url}" /> | |||||||||||||||||||||||||||||
| <meta property="og:type" content="website" /> | |||||||||||||||||||||||||||||
| <meta property="og:site_name" content="pyplots.ai" /> | |||||||||||||||||||||||||||||
| <meta name="twitter:card" content="summary_large_image" /> | |||||||||||||||||||||||||||||
| <meta name="twitter:title" content="{title}" /> | |||||||||||||||||||||||||||||
| <meta name="twitter:description" content="{description}" /> | |||||||||||||||||||||||||||||
| <meta name="twitter:image" content="{image}" /> | |||||||||||||||||||||||||||||
| <link rel="canonical" href="{url}" /> | |||||||||||||||||||||||||||||
| </head> | |||||||||||||||||||||||||||||
| <body><h1>{title}</h1><p>{description}</p></body> | |||||||||||||||||||||||||||||
| </html>""" | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| DEFAULT_IMAGE = "https://pyplots.ai/og-image.png" | |||||||||||||||||||||||||||||
| DEFAULT_DESCRIPTION = "Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained." | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| @router.get("/sitemap.xml") | |||||||||||||||||||||||||||||
| async def get_sitemap(db: AsyncSession | None = Depends(optional_db)): | |||||||||||||||||||||||||||||
| """ | |||||||||||||||||||||||||||||
|
|
@@ -53,3 +79,102 @@ | ||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| set_cache(key, xml) | |||||||||||||||||||||||||||||
| return Response(content=xml, media_type="application/xml") | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| # ============================================================================= | |||||||||||||||||||||||||||||
| # Bot SEO Proxy Endpoints | |||||||||||||||||||||||||||||
| # These endpoints serve HTML with correct meta tags for social media bots. | |||||||||||||||||||||||||||||
| # nginx proxies bot requests here based on User-Agent detection. | |||||||||||||||||||||||||||||
| # ============================================================================= | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| @router.get("/seo-proxy/") | |||||||||||||||||||||||||||||
| async def seo_home(): | |||||||||||||||||||||||||||||
| """Bot-optimized home page with correct og:tags.""" | |||||||||||||||||||||||||||||
| return HTMLResponse( | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE.format( | |||||||||||||||||||||||||||||
| title="pyplots.ai", | |||||||||||||||||||||||||||||
| description=DEFAULT_DESCRIPTION, | |||||||||||||||||||||||||||||
| image=DEFAULT_IMAGE, | |||||||||||||||||||||||||||||
| url="https://pyplots.ai/", | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| @router.get("/seo-proxy/catalog") | |||||||||||||||||||||||||||||
| async def seo_catalog(): | |||||||||||||||||||||||||||||
| """Bot-optimized catalog page with correct og:tags.""" | |||||||||||||||||||||||||||||
| return HTMLResponse( | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE.format( | |||||||||||||||||||||||||||||
| title="Catalog | pyplots.ai", | |||||||||||||||||||||||||||||
| description="Browse all Python plotting specifications alphabetically. Find matplotlib, seaborn, plotly, bokeh, altair examples.", | |||||||||||||||||||||||||||||
| image=DEFAULT_IMAGE, | |||||||||||||||||||||||||||||
| url="https://pyplots.ai/catalog", | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| @router.get("/seo-proxy/{spec_id}") | |||||||||||||||||||||||||||||
| async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(optional_db)): | |||||||||||||||||||||||||||||
| """Bot-optimized spec overview page with correct og:tags.""" | |||||||||||||||||||||||||||||
| if db is None: | |||||||||||||||||||||||||||||
| # Fallback when DB unavailable | |||||||||||||||||||||||||||||
| return HTMLResponse( | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE.format( | |||||||||||||||||||||||||||||
| title=f"{spec_id} | pyplots.ai", | |||||||||||||||||||||||||||||
| description=DEFAULT_DESCRIPTION, | |||||||||||||||||||||||||||||
| image=DEFAULT_IMAGE, | |||||||||||||||||||||||||||||
| url=f"https://pyplots.ai/{html.escape(spec_id)}", | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
|
Comment on lines
+122
to
+127
Check warningCode scanning / CodeQL Reflected server-side cross-site scripting Medium
Cross-site scripting vulnerability due to a
user-provided value Error loading related location Loading
Copilot AutofixAI 4 months ago In general, to fix reflected server-side XSS, every user-controlled value inserted into an HTML document must be properly escaped for the context in which it appears (HTML body, attribute, URL, etc.). In this file, all uses of The single best minimal fix is to escape
Suggested changeset
1
api/routers/seo.py
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
Refresh and try again.
|
|||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| repo = SpecRepository(db) | |||||||||||||||||||||||||||||
| spec = await repo.get_by_id(spec_id) | |||||||||||||||||||||||||||||
| if not spec: | |||||||||||||||||||||||||||||
| raise HTTPException(status_code=404, detail="Spec not found") | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| return HTMLResponse( | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE.format( | |||||||||||||||||||||||||||||
| title=f"{html.escape(spec.title)} | pyplots.ai", | |||||||||||||||||||||||||||||
| description=html.escape(spec.description or DEFAULT_DESCRIPTION), | |||||||||||||||||||||||||||||
| image=DEFAULT_IMAGE, | |||||||||||||||||||||||||||||
| url=f"https://pyplots.ai/{html.escape(spec_id)}", | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||
| @router.get("/seo-proxy/{spec_id}/{library}") | |||||||||||||||||||||||||||||
| async def seo_spec_implementation( | |||||||||||||||||||||||||||||
| spec_id: str, | |||||||||||||||||||||||||||||
| library: str, | |||||||||||||||||||||||||||||
| db: AsyncSession | None = Depends(optional_db), | |||||||||||||||||||||||||||||
| ): | |||||||||||||||||||||||||||||
| """Bot-optimized spec implementation page with dynamic og:image from preview_url.""" | |||||||||||||||||||||||||||||
| if db is None: | |||||||||||||||||||||||||||||
| # Fallback when DB unavailable | |||||||||||||||||||||||||||||
| return HTMLResponse( | |||||||||||||||||||||||||||||
| BOT_HTML_TEMPLATE.format( | |||||||||||||||||||||||||||||
| title=f"{spec_id} - {library} | pyplots.ai", | |||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||
| title=f"{spec_id} - {library} | pyplots.ai", | |
| title=f"{html.escape(spec_id)} - {html.escape(library)} | pyplots.ai", |
Check warning
Code scanning / CodeQL
Reflected server-side cross-site scripting Medium
Cross-site scripting vulnerability due to a user-provided value.
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 months ago
In general, to fix reflected server-side XSS in this endpoint, all user-controlled values (spec_id, library) must be HTML-escaped before being interpolated into BOT_HTML_TEMPLATE, not only when used in URLs but also when used in text nodes like the <title> element. The Python standard library’s html.escape() is already imported and used for some fields; we should extend its use to every occurrence where raw user input is inserted into the template.
Concretely, in seo_spec_implementation’s DB-unavailable fallback (lines ~155–161), the title field currently embeds spec_id and library without escaping. We should wrap these in html.escape() like is already done for the url field. This preserves existing functionality (the same values are displayed) but ensures any <, >, &, quotes, etc. are encoded and cannot break out of the HTML context. No new imports or helpers are needed; we only adjust the f-string expressions in that block. The rest of the function already escapes user-derived values where necessary.
-
Copy modified line R157
| @@ -154,7 +154,7 @@ | ||
| # Fallback when DB unavailable | ||
| return HTMLResponse( | ||
| BOT_HTML_TEMPLATE.format( | ||
| title=f"{spec_id} - {library} | pyplots.ai", | ||
| title=f"{html.escape(spec_id)} - {html.escape(library)} | pyplots.ai", | ||
| description=DEFAULT_DESCRIPTION, | ||
| image=DEFAULT_IMAGE, | ||
| url=f"https://pyplots.ai/{html.escape(spec_id)}/{html.escape(library)}", |
Copilot
AI
Jan 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The preview_url from the database is inserted directly into the HTML template without HTML escaping. This could lead to XSS vulnerabilities if the preview_url contains malicious content. The image variable should be HTML-escaped before being used in the template, similar to how spec.title and spec.description are escaped.
| return HTMLResponse( | |
| BOT_HTML_TEMPLATE.format( | |
| title=f"{html.escape(spec.title)} - {html.escape(library)} | pyplots.ai", | |
| description=html.escape(spec.description or DEFAULT_DESCRIPTION), | |
| image=image, | |
| escaped_image = html.escape(image, quote=True) | |
| return HTMLResponse( | |
| BOT_HTML_TEMPLATE.format( | |
| title=f"{html.escape(spec.title)} - {html.escape(library)} | pyplots.ai", | |
| description=html.escape(spec.description or DEFAULT_DESCRIPTION), | |
| image=escaped_image, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,19 @@ | ||
| # Bot detection for SEO - social media crawlers need pre-rendered meta tags | ||
| map $http_user_agent $is_bot { | ||
| default 0; | ||
| ~*twitterbot 1; | ||
| ~*facebookexternalhit 1; | ||
| ~*linkedinbot 1; | ||
| ~*slackbot 1; | ||
| ~*telegrambot 1; | ||
| ~*whatsapp 1; | ||
| ~*googlebot 1; | ||
| ~*bingbot 1; | ||
| ~*discordbot 1; | ||
| ~*pinterestbot 1; | ||
| ~*applebot 1; | ||
| } | ||
|
|
||
| server { | ||
| listen 8080; | ||
| server_name _; | ||
|
|
@@ -25,8 +41,21 @@ server { | |
| add_header Expires "0"; | ||
| } | ||
|
|
||
| # Named location for bot SEO proxy | ||
| location @seo_proxy { | ||
| proxy_pass https://api.pyplots.ai/seo-proxy$request_uri; | ||
| proxy_set_header Host api.pyplots.ai; | ||
| proxy_ssl_server_name on; | ||
| } | ||
|
Comment on lines
+44
to
+51
|
||
|
|
||
| # SPA routing - serve index.html for all routes | ||
| # Bots get redirected to backend for proper meta tags | ||
| location / { | ||
| # Redirect bots to SEO proxy via error_page trick (nginx-safe pattern) | ||
| error_page 418 = @seo_proxy; | ||
| if ($is_bot) { | ||
| return 418; | ||
| } | ||
| try_files $uri $uri/ /index.html; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The spec_id in the title is not HTML-escaped, which could lead to XSS if a malicious spec_id is provided. All user-controlled inputs should be escaped before insertion into HTML. The url parameter already escapes it correctly, but the title needs the same treatment.