Every security mechanism in Sharetopus, organized by the attack it stops, the code that implements the defense, and the known gaps.
- Defense Layers
- Threat Model
- Identity Flow
- Rate Limiting
- Idempotency
- SSRF Guard
- Storage Quota Enforcement
- TikTok HMAC Media Proxy
- Webhook Verification
- Append-Only Audit
- Data Protection
- XSS Prevention
- Known Gaps
- Compliance Posture
- Source Files Referenced
Every inbound request passes through these layers in order. A failure at any layer rejects the request immediately.
flowchart LR
A["Network\n(TLS, DNS)"] --> B["Rate Limit\n(Upstash sliding window)"]
B --> C["Auth\n(API key or Clerk JWT)"]
C --> D["Entitlement\n(plan tier + monthly quota)"]
D --> E["Handler\n(tool logic, SSRF guard,\nquota check)"]
E --> F["Audit\n(append-only log,\narg redaction)"]
style A fill:#e8e8e8,stroke:#666
style B fill:#ffd6d6,stroke:#c44
style C fill:#fff3cd,stroke:#a80
style D fill:#d4edda,stroke:#2a5
style E fill:#cce5ff,stroke:#36a
style F fill:#e2d5f1,stroke:#74b
MCP route-level rate limiting (100/60s per IP) runs before auth. All other rate limits run after auth, scoped to the authenticated principal.
| # | Attack | Defense | Implementation |
|---|---|---|---|
| 1 | Duplicate posts from MCP retry | idempotency_key + DB UNIQUE constraint |
schedulePost.ts, postNow.ts, bulkPostNow.ts, bulkSchedule.ts |
| 2 | SSRF via attach_media_from_url (e.g. cloud metadata at 169.254.169.254) |
safeUserFetch with 14 IP ranges blocked |
safeUserFetch.ts |
| 3 | Oversized file with fake Content-Length | Stream-based byte counter (Content-Length never trusted) | safeUserFetch.ts |
| 4 | Storage quota bypass | enforceStorageQuota via get_user_storage_bytes RPC |
enforceStorageQuota.ts |
| 5 | attach_media_from_url flood |
10/60s rate limit + monthly cap per tier | attachMediaFromUrl.ts |
| 6 | Cross-user storage access | Path startsWith(principalId/) check |
/api/storage/generate-view-url, /api/media |
| 7 | TikTok media URL forgery | HMAC-SHA256 + 30-min expiry | buildProxiedTikTokMediaUrl.ts |
| 8 | Media proxy path traversal | Block .., //, leading / |
/api/media/route.ts |
| 9 | Audit log tampering | Append-only table (Update: never, DB trigger) | mcp_audit_log table |
| 10 | Concurrent quota race condition | atomic_increment_quota Postgres function |
entitlement.ts |
| 11 | Monthly cap exhaustion | Per-tier quotas enforced atomically | entitlement.ts |
| 12 | IP tracking privacy leak | SHA-256 hash with configurable salt | ipHash.ts |
| 13 | Sensitive args in audit log | Regex redaction of 12 key patterns + JWT detection | audit.ts |
| 14 | Unauthorized MCP access flood | Route-level rate limit: 100/60s per IP (before auth) | MCP route handler |
| 15 | XSS via MCP clientInfo |
sanitizeClientField strips control chars + HTML injection chars |
MCP server init |
| 16 | Stripe webhook replay | Signature verification + stripe_webhook_events idempotency table |
Stripe webhook handler |
| 17 | TikTok webhook replay | HMAC-SHA256 + 300s tolerance + tiktok_webhook_events idempotency table |
TikTok webhook handler |
| 18 | REST API unauthorized access | Bearer token (stp_rest_*) + SHA-256 hash lookup + revocation check |
withRestEndpoint.ts |
| 19 | REST API request forgery | Per-principal rate limiting + append-only rest_audit_log |
withRestEndpoint.ts, writeRestAuditLog.ts |
| 20 | Outbound webhook forgery | HMAC-SHA256 per-subscription signing with X-Sharetopus-Signature header |
signWebhookPayload.ts, deliverWebhook.ts |
| 21 | Webhook endpoint abuse | Auto-disable after 10 consecutive delivery failures | deliverWebhook.ts (AUTO_DISABLE_THRESHOLD = 10) |
Three auth surfaces share the same identity model. MCP and REST API both resolve to a principal with subscription tier. Free and Starter tiers are blocked at the MCP subscription gate (Creator+ required for MCP). REST API auth is handled by withRestEndpoint in src/lib/api/rest/middleware/withRestEndpoint.ts, which resolves stp_rest_* Bearer tokens via SHA-256 hash lookup in api_keys (where kind = 'rest'). Every REST request is logged to the append-only rest_audit_log table.
sequenceDiagram
participant C as Client (browser / MCP agent)
participant R as Route handler
participant A as resolveMcpPrincipal
participant Clerk as Clerk SDK
participant DB as Supabase
C->>R: Request with Authorization: Bearer <token>
R->>A: Extract bearer token
alt Token starts with stp_mcp_
A->>A: SHA-256 hash the token
A->>DB: SELECT from api_keys WHERE token_hash = hash
A->>A: Verify: not revoked, not expired
A->>DB: UPDATE api_keys SET last_used_at = now()
else Clerk OAuth token (JWT)
A->>Clerk: Verify JWT (signature, expiry, audience)
Clerk-->>A: userId
end
A->>DB: checkActiveSubscription (stripe_subscriptions)
DB-->>A: { isActive, plan, priceId }
alt not active OR plan below Creator
A-->>R: null (401 invalid_token, fail-closed)
else active with Creator+ plan
A->>A: priceIdToTier(priceId)
A-->>R: McpPrincipal { principalId, kind, plan, priceId, scopes }
R->>R: Inject principal into tool context
end
Fail-closed behavior. If checkActiveSubscription returns isActive: false, errors, or the plan is below Creator, the request is blocked. No principal is returned.
McpPrincipal type (discriminated union):
kind: "apikey"carriesapiKeyId,scopeskind: "oauth"carriesoauthClientId,scopes- Both carry
principalId,plan(PlanTier),priceId
The plan tier is resolved once during auth and cached on the principal. Tools and entitlement checks read principal.plan without querying stripe_subscriptions again.
All per-request rate limits use Upstash Redis sliding window (@upstash/ratelimit).
| Path | Scope | Limit | Window | Storage |
|---|---|---|---|---|
| MCP route (pre-auth) | per IP | 100 | 60s | Upstash |
attach_media_from_url |
per principal | 10 | 60s | Upstash |
request_upload_url |
per principal | 20 | 60s | Upstash |
handleSocialMediaPost |
per user | 30 | 60s | Upstash |
checkOutSession |
per user | 15 | 60s | Upstash |
createCustomerPortal |
per user | 20 | 60s | Upstash |
directPostBatch |
per source | 20 | 60s | Upstash |
schedulePostBatch |
per source | 10 | 60s | Upstash |
| REST API endpoints | per principal | varies per endpoint | varies | Upstash |
| DCR registration | per IP | 1/min, 10/day | Supabase rate_limit_events |
|
| Pinterest board listing | per account | 15 | 60s | Upstash |
Enforced by entitlementFor in src/lib/mcp/entitlement.ts via atomic_increment_quota Postgres RPC. The increment is atomic at the database level, preventing race conditions between concurrent requests.
| Action | Creator | Pro |
|---|---|---|
schedule_post |
500 | unlimited |
post_now |
500 | unlimited |
request_upload_url |
500 | unlimited |
attach_media_from_url |
500 | unlimited |
bulk_schedule |
200 | unlimited |
bulk_post_now |
500 | unlimited |
generate_post_draft |
100 | unlimited |
Starter tier: 0 for all actions (completely blocked from MCP at the auth gate).
Period key format: YYYY-MM-01 (first of month, UTC), generated by currentQuotaPeriod().
MCP write tools that create posts support Stripe-style idempotent retries. An agent that retries after a network error with the same idempotency_key gets back the original result instead of creating a duplicate.
sequenceDiagram
participant A as Agent
participant T as Tool handler
participant DB as Supabase
A->>T: post_now {idempotency_key: "abc-123"}
T->>DB: INSERT pending_direct_posts ON CONFLICT DO NOTHING
DB-->>T: 1 row inserted
T->>T: Dispatch Inngest post.now event
T->>A: { event_id: "evt_1", success: true }
Note over A: Network blip. Agent retries.
A->>T: post_now {idempotency_key: "abc-123"}
T->>DB: INSERT pending_direct_posts ON CONFLICT DO NOTHING
DB-->>T: 0 rows inserted (conflict on principal_id + idempotency_key)
T->>DB: SELECT WHERE principal_id = ? AND idempotency_key = "abc-123"
DB-->>T: existing event_id
T->>A: { event_id: "evt_1", message: "already dispatched" }
| Tool | Key source | Target table |
|---|---|---|
schedule_post |
idempotency_key param (optional, 1-200 chars) |
scheduled_posts |
post_now |
idempotency_key param (optional, 1-200 chars) |
pending_direct_posts |
bulk_schedule |
Derived: ${batchId}:${index} from batch_id param |
scheduled_posts |
bulk_post_now |
Derived: ${batch_id}:${index} from batch_id param |
pending_direct_posts |
DB enforcement: partial unique index on (principal_id, idempotency_key) on both tables. All four tools use INSERT ... ON CONFLICT DO NOTHING.
The key is optional. Omitting it means no deduplication (every call creates a new row). Agents should always supply one when retries are possible.
The scheduled-posts-tick cron uses eventId = ${postId}:${scheduledAt} with a 24-hour dedup window in Inngest, preventing duplicate dispatch if the cron fires twice for the same batch.
safeUserFetch protects attach_media_from_url from Server-Side Request Forgery. An agent could submit a URL pointing at internal infrastructure (cloud metadata endpoints, private services). The guard blocks this at multiple layers.
flowchart TD
A[args.url from agent] --> B{URL parses?}
B -- no --> X1[reject: invalid_url]
B -- yes --> C{Scheme http or https?}
C -- no --> X2[reject: blocked_scheme]
C -- yes --> D{Hostname is literal IP?}
D -- yes --> E[Validate IP directly]
D -- no --> F["DNS lookup (all: true, verbatim: false)"]
F --> G{Every resolved IP safe?}
E --> G
G -- no --> X3[reject: blocked_ip]
G -- yes --> H["fetch(url, redirect: 'manual')"]
H --> I{3xx response?}
I -- yes --> X4[reject: redirect_not_allowed]
I -- no --> J{Content-type in allowlist?}
J -- no --> X5[reject: content_type_not_allowed]
J -- yes --> K[Stream body with byte counter]
K --> L{Bytes exceed maxBytes?}
L -- yes --> X6[abort: too_large]
L -- no --> M[Return Buffer + metadata]
style X1 fill:#fdd,stroke:#c44
style X2 fill:#fdd,stroke:#c44
style X3 fill:#fdd,stroke:#c44
style X4 fill:#fdd,stroke:#c44
style X5 fill:#fdd,stroke:#c44
style X6 fill:#fdd,stroke:#c44
style M fill:#dfd,stroke:#2a5
14 ranges covering IPv4 and IPv6. Unrecognized IP formats fail closed (treated as blocked).
IPv4:
| CIDR | Description |
|---|---|
0.0.0.0/8 |
Unspecified |
10.0.0.0/8 |
Private (RFC 1918) |
100.64.0.0/10 |
CGNAT (RFC 6598) |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local |
172.16.0.0/12 |
Private (RFC 1918) |
192.168.0.0/16 |
Private (RFC 1918) |
224.0.0.0/4 |
Multicast |
240.0.0.0/4 |
Reserved + broadcast |
IPv6:
| CIDR | Description |
|---|---|
::1/128 |
Loopback |
::/128 |
Unspecified |
fe80::/10 |
Link-local |
fc00::/7 |
Unique Local Address (ULA) |
::ffff:0:0/96 |
IPv4-mapped (embedded IPv4 re-checked against IPv4 ranges) |
- Scheme validation. Only
http:andhttps:are allowed.file:,ftp:,data:,gopher:are rejected. - Redirect blocking.
fetchis called withredirect: "manual". Any 3xx response is rejected. This prevents TOCTOU attacks where DNS resolves to a safe IP but the redirect target is internal. - Content-type validation. Response content-type is checked against an allowlist (prefix match + exact match). Strips parameters before comparison.
- Stream-based byte counter. Body is read chunk-by-chunk via
response.body.getReader(). A runningbyteCountis compared againstmaxByteson each chunk. If exceeded, the fetch is aborted. The Content-Length header is never trusted. - Timeouts. Connect timeout (5s) and total timeout (30s) via AbortController.
- User-Agent. Requests identify as
Sharetopus-MCP/1.0.
Three upload paths all converge on a single enforcement function.
flowchart LR
subgraph "Upload paths"
A1["Web /api/storage\n/generate-upload-url"]
A2["MCP\nrequest_upload_url"]
A3["MCP\nattach_media_from_url"]
end
subgraph "Quota check"
E["enforceStorageQuota()"]
F["get_user_storage_bytes\nPostgres RPC"]
E --> F
end
subgraph "Storage"
S[("scheduled-videos\nbucket")]
end
A1 --> E
A2 --> E
A3 --> SSRF["safeUserFetch\n(SSRF guard)"] --> SizeCap["Per-type\nsize cap"] --> E
E -->|"projected <= cap"| Mint["Mint signed URL\nor upload"]
E -->|"projected > cap"| Reject["Return quota_exceeded"]
Mint --> S
style Reject fill:#fdd,stroke:#c44
style Mint fill:#dfd,stroke:#2a5
enforceStorageQuota is the single enforcement point for all three upload paths:
- Calls
get_user_storage_bytesPostgres RPC with_bucket = "scheduled-videos"and_prefix = "{principalId}/". - The RPC reads
storage.objectsdirectly (no pagination, no estimation). - Computes
projected = currentBytes + additionalBytes. - Compares against
STORAGE_LIMITS[priceId](falls back to 5 GB default). - Returns allow or deny with current/cap in the error message.
| Plan | Storage cap |
|---|---|
| Starter | 5 GB |
| Creator | 15 GB |
| Pro | 45 GB |
| Default (unknown priceId) | 5 GB |
Per-file size caps (all plans): image 8 MB, video 250 MB.
TikTok's pull model requires a publicly accessible URL for media. Sharetopus uses an HMAC-signed proxy (/api/media) to serve files without exposing storage credentials.
sequenceDiagram
participant Worker as Inngest worker
participant Builder as buildProxiedTikTokMediaUrl
participant TikTok as TikTok API
participant Proxy as /api/media
participant SB as Supabase Storage
Worker->>Builder: buildProxiedTikTokMediaUrl(principalId, mediaPath)
Builder->>Builder: HMAC payload: principalId + ":" + mediaPath + ":" + expiresAt
Builder->>Builder: Sign with MEDIA_PROXY_HMAC_SECRET (SHA-256)
Builder-->>Worker: Signed URL with ?file=&user=&expires=&sig=
Worker->>TikTok: POST /v2/post/publish/video/init/ (video_url = signed URL)
TikTok->>TikTok: Queue pull
Note over TikTok,Proxy: Minutes pass. TikTok pulls the media.
TikTok->>Proxy: GET /api/media?file=...&user=...&expires=...&sig=...
Proxy->>Proxy: 1. Verify HMAC signature (timingSafeEqual)
Proxy->>Proxy: 2. Check expiry (410 Gone if expired)
Proxy->>Proxy: 3. Check path startsWith user/ (403 if not)
Proxy->>Proxy: 4. Block ".." and "//" (400 if traversal)
Proxy->>SB: createSignedUrl(path, 600s TTL)
SB-->>Proxy: Signed URL
Proxy->>Proxy: Fetch file from signed URL
Proxy-->>TikTok: Stream body (Cache-Control: private, no-store)
HMAC details:
- Algorithm: SHA-256
- Secret:
MEDIA_PROXY_HMAC_SECRETenv var (64 hex chars) - Payload:
${principalId}:${mediaPath}:${expiresAt}(colon-separated) - Expiry window: 30 minutes from URL creation
- Signature comparison:
crypto.timingSafeEqual(constant-time, prevents timing attacks) - Response: proxy streams the file body directly (no redirect).
Cache-Control: private, no-store.
Stripe webhooks are verified using the Stripe SDK's built-in signature check against STRIPE_WEBHOOK_SECRET. Processed events are recorded in the stripe_webhook_events table (append-only) for idempotency. If an event ID already exists, the handler returns early without reprocessing.
TikTok webhooks are verified with HMAC-SHA256. The signed payload is ${timestamp}.${rawBody}, where the timestamp comes from the request header. Verification enforces a 300-second tolerance window to reject stale or replayed requests. Processed events are recorded in the tiktok_webhook_events table (append-only) for idempotency.
Clerk webhooks are verified using the Svix library's signature verification. This validates the webhook signature, timestamp, and payload integrity.
User-created webhook subscriptions receive HMAC-SHA256 signed deliveries. Each subscription has a unique secret (generated at creation via secretGenerator.ts).
- Algorithm: HMAC-SHA256
- Payload signed: the full JSON body string
- Header:
X-Sharetopus-Signature: sha256=<hex digest> - Additional headers:
X-Sharetopus-Event(event type),X-Sharetopus-Delivery(delivery UUID),User-Agent: Sharetopus-Webhook/1.0 - Delivery timeout: 10 seconds
- Retries: 3 (via Inngest backoff) for retryable failures (5xx, 408, 429, network errors)
- Auto-disable: subscriptions are disabled after 10 consecutive failures (
AUTO_DISABLE_THRESHOLDindeliverWebhook.ts) - Re-enable: PATCH
/api/v1/webhooks/:idwith{"active": true}resetsfailure_countto 0
Verification: recipients should compute HMAC-SHA256(rawBody, subscription_secret) and compare with constant-time comparison against the sha256= value in the X-Sharetopus-Signature header.
Nine tables are append-only (Update: never in database types, enforced at the DB layer):
| Table | Purpose | Retention |
|---|---|---|
mcp_audit_log |
Every MCP tool call with redacted args, result status, latency | 90 days (cleanup cron) |
rest_audit_log |
Every REST API request with endpoint, method, status code, latency | Grows indefinitely (no cleanup cron yet) |
stripe_invoices |
Payment records | Indefinite |
stripe_webhook_events |
Stripe webhook idempotency | 90 days (cleanup cron) |
tiktok_webhook_events |
TikTok webhook idempotency | Indefinite |
wallet_credits_ledger |
Credit transaction history (x402, deferred) | Indefinite |
x402_access_log |
Access audit trail (x402, deferred) | Indefinite |
x402_refunds |
Refund records (x402, deferred) | Indefinite |
sanctions_screenings |
Wallet sanctions check results (x402, deferred) | Indefinite |
The mcp_audit_log insert happens in logToolCall, which runs fire-and-forget after every tool call. Arguments are redacted before insert:
- 12 key patterns replaced with
[REDACTED]: token, password, secret, authorization, bearer, api_key, apikey, access_token, refresh_token, credential, private_key, jwt. - JWT detector replaces strings matching the three-segment base64url pattern (
xxx.yyy.zzz) with[REDACTED_JWT]. - Truncation to 4096 chars after redaction.
Raw client IPs are never stored. All IP addresses are hashed before persistence.
- Algorithm: SHA-256
- Input:
ip + ":" + salt - Output: truncated to 32 hex chars
- Salt:
MCP_IP_HASH_SALTenv var, required in production (server throws if missing) - Development: fallback salt with a warning log
Token, password, secret, and JWT patterns are redacted before insert (see Argument redaction above).
| Data | Retention |
|---|---|
mcp_audit_log |
90 days (via cleanup-mcp-audit-log cron) |
rest_audit_log |
Grows indefinitely (no cleanup cron yet) |
stripe_webhook_events |
90 days (via cleanup-stripe-webhook-events cron) |
| Cancelled scheduled posts | 7-day grace period |
stripe_invoices |
Grows indefinitely (no cleanup) |
content_history |
Grows indefinitely (no cleanup) |
MCP clients send a clientInfo object containing clientName and clientVersion during session initialization. These values are stored and rendered in the admin dashboard.
sanitizeClientField strips:
- ASCII control characters (0x00-0x1f)
- HTML injection characters:
<,>,',",&
This prevents stored-XSS when rendering client names in the dashboard. Field length limits: clientName max 200 chars, clientVersion max 50 chars.
These are acknowledged design decisions or low-severity issues, not bugs.
| # | Gap | Severity | Notes |
|---|---|---|---|
| 1 | Cancelled scheduled_posts hold storage indefinitely |
Design decision | Orphan sweep only catches unreferenced files. Cancelled posts still reference their media. |
| 2 | expiresIn on /api/storage/generate-view-url not capped server-side |
Low | Own files only. Client could request a long-lived signed URL. |
| 3 | No alerting on orphan sweep counts | Low | Sweep logs stats to Inngest but no external notification hook. |
| 4 | No rate limit on view URL and media proxy endpoints | Low | Both require authentication (Clerk or HMAC). Abuse would require valid credentials. |
| 5 | No aggregate daily cap on attach_media_from_url |
Low | Per-minute (10/60s) and per-month caps exist. A sustained 10/min attack over 24h would hit the monthly cap within a day for Creator users. |
- PII redaction in audit logs. Token, password, secret, JWT patterns are redacted before insert.
- IP hashing. Raw client IPs are never stored. SHA-256 hashed with configurable salt.
- Append-only financial tables.
stripe_invoicescannot be updated or deleted at the DB layer. - 90-day audit retention.
mcp_audit_logandstripe_webhook_eventsare cleaned up by scheduled crons.
- OFAC / FINTRAC / MiCA screening. The
sanctions_screeningstable exists but no screening service is integrated. - Wallet KYC. No identity verification for wallet-based access.
- USDC fair market value tracking. The
usdc_fmv_dailytable exists but is not populated.
| File | Role |
|---|---|
src/lib/mcp/tools/schedulePost.ts |
Schedule post with idempotency |
src/lib/mcp/tools/postNow.ts |
Immediate post with idempotency |
src/lib/mcp/tools/bulkPostNow.ts |
Bulk immediate post with derived idempotency keys |
src/lib/mcp/tools/bulkSchedule.ts |
Bulk schedule with derived idempotency keys |
src/lib/mcp/tools/attachMediaFromUrl.ts |
URL-based media attach (rate limited, SSRF guarded) |
src/lib/mcp/tools/requestUploadUrl.ts |
Signed upload URL generation (rate limited) |
src/lib/mcp/tools/generatePostDraft.ts |
AI draft generation (monthly capped) |
src/lib/mcp/_shared/safeUserFetch.ts |
SSRF guard (IP blocklist, scheme, redirect, byte counter) |
src/lib/mcp/_shared/enforceStorageQuota.ts |
Storage quota enforcement for all upload paths |
src/lib/mcp/_shared/currentQuotaPeriod.ts |
Monthly quota period key generation |
src/lib/mcp/entitlement.ts |
Plan tier checks, monthly cap enforcement via atomic_increment_quota |
src/lib/mcp/audit.ts |
Audit logging with arg redaction and JWT detection |
src/lib/mcp/ipHash.ts |
IP hashing (SHA-256 with salt) |
src/lib/api/tiktok/buildProxiedTikTokMediaUrl.ts |
HMAC-signed TikTok media URL builder |
src/app/api/media/route.ts |
Media proxy (HMAC verification, path traversal guard) |
src/app/api/storage/generate-view-url/route.ts |
Web view URL generation (Clerk auth, path ownership) |
src/app/api/storage/generate-upload-url/route.ts |
Web upload URL generation (storage quota check) |
src/lib/api/rest/middleware/withRestEndpoint.ts |
REST API endpoint wrapper (auth, validation, audit, rate limit) |
src/lib/api/rest/audit/writeRestAuditLog.ts |
REST API audit log writer |
src/lib/api/rest/webhooks/signWebhookPayload.ts |
HMAC-SHA256 signing for outbound webhooks |
src/lib/api/rest/webhooks/secretGenerator.ts |
Webhook subscription secret generation |
src/inngest/functions/deliverWebhook.ts |
Webhook delivery with retry and auto-disable |
See also: docs/AUTH.md (principal model, auth paths), docs/MCP.md (tool inventory, annotations, idempotency), docs/REST.md (REST API endpoints, withRestEndpoint HOF), docs/WEBHOOKS.md (webhook subsystem, signing, replay), docs/STORAGE.md (upload paths, orphan sweep)