diff --git a/backend/api_core.py b/backend/api_core.py index 9999316..6a3086a 100644 --- a/backend/api_core.py +++ b/backend/api_core.py @@ -929,6 +929,17 @@ def check_payment_circumvention(text): return False, "Payment instructions must stay on-platform. Do not include direct payment links, wallet addresses, or off-platform payment instructions." return True, None + +def is_safe_external_url(value): + if not value: + return True + try: + parsed = urllib.parse.urlparse(str(value).strip()) + except Exception: + return False + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + VALID_CATEGORIES = [ 'web_development', 'mobile_development', 'software_development', 'graphic_design', 'ui_ux_design', 'video_editing', 'photography', @@ -2295,7 +2306,21 @@ def _handle_routes(db): if budget <= 0 or budget > 1000000: return error_response("budget_amount must be positive and <= 1,000,000") - safe, msg = check_content_safety(body['title'] + " " + body['description']) + job_text_parts = [ + body.get('title', ''), + body.get('description', ''), + body.get('location_detail', ''), + ] + raw_required_skills = body.get('required_skills', []) + if isinstance(raw_required_skills, list): + job_text_parts.extend(str(skill) for skill in raw_required_skills) + else: + job_text_parts.append(str(raw_required_skills or '')) + job_safety_text = " ".join(str(part or '') for part in job_text_parts) + safe, msg = check_content_safety(job_safety_text) + if not safe: + return error_response(f"Job rejected: {msg}", 422) + safe, msg = check_payment_circumvention(job_safety_text) if not safe: return error_response(f"Job rejected: {msg}", 422) @@ -2359,11 +2384,23 @@ def _handle_routes(db): return error_response("Can only edit open or reviewing jobs", 409) body = get_body() - if body.get('title') or body.get('description'): - txt = (body.get('title') or job['title']) + " " + (body.get('description') or job['description']) - safe, msg = check_content_safety(txt) - if not safe: - return error_response(f"Job update rejected: {msg}", 422) + update_text_parts = [ + body.get('title', job['title']), + body.get('description', job['description']), + body.get('location_detail', job['location_detail'] or ''), + ] + update_skills = body.get('required_skills', job['required_skills'] or '') + if isinstance(update_skills, list): + update_text_parts.extend(str(skill) for skill in update_skills) + else: + update_text_parts.append(str(update_skills or '')) + update_safety_text = " ".join(str(part or '') for part in update_text_parts) + safe, msg = check_content_safety(update_safety_text) + if not safe: + return error_response(f"Job update rejected: {msg}", 422) + safe, msg = check_payment_circumvention(update_safety_text) + if not safe: + return error_response(f"Job update rejected: {msg}", 422) updates = [] vals = [] @@ -2450,6 +2487,17 @@ def _handle_routes(db): ensure_worker_profile(db, user['id']) body = get_body() + cover_message = body.get("cover_message", "") + portfolio_url = body.get("portfolio_url", "") + application_safety_text = " ".join([str(cover_message or ""), str(portfolio_url or "")]) + safe, msg = check_content_safety(application_safety_text) + if not safe: + return error_response(f"Application rejected: {msg}", 422) + safe, msg = check_payment_circumvention(application_safety_text) + if not safe: + return error_response(f"Application rejected: {msg}", 422) + if not is_safe_external_url(portfolio_url): + return error_response("Application rejected: portfolio_url must be a valid http(s) URL", 422) existing = db.execute( "SELECT id FROM applications WHERE job_id = ? AND worker_id = ?", [job_id, user['id']] @@ -2459,7 +2507,7 @@ def _handle_routes(db): cursor = db.execute( "INSERT INTO applications (job_id, worker_id, cover_message, portfolio_url) VALUES (?,?,?,?)", - [job_id, user['id'], body.get("cover_message", ""), body.get("portfolio_url", "")] + [job_id, user['id'], cover_message, portfolio_url] ) app_id = cursor.lastrowid diff --git a/backend/test_deep_audit_regressions.py b/backend/test_deep_audit_regressions.py index 3656d4f..9e8a564 100644 --- a/backend/test_deep_audit_regressions.py +++ b/backend/test_deep_audit_regressions.py @@ -319,6 +319,92 @@ def test_service_update_rejects_off_platform_payment_instructions_in_tags(self): self.assertEqual(status, 422, response) self.assertIn("Payment instructions must stay on-platform", response.get("error", "")) + def test_job_creation_rejects_off_platform_payment_instructions(self): + db = self.module.get_db() + token = "tok-employer" + try: + db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (1,'employer@example.com','x','Employer')") + db.execute("INSERT INTO employer_profiles (user_id) VALUES (1)") + db.execute("INSERT INTO sessions (user_id,token,expires_at) VALUES (1,?,datetime('now','+1 day'))", [token]) + db.commit() + finally: + db.close() + + payload = { + "title": "Pay me via PayPal", + "description": "Review the website and use direct payment through paypal.me/example.", + "category": "testing", + "budget_type": "fixed", + "budget_amount": 25, + "required_skills": ["qa", "zelle accepted"], + } + body = json.dumps(payload) + self.module._request_ctx.request_method = "POST" + self.module._request_ctx.path_info = "/api/v1/jobs" + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = f"Bearer {token}" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = body + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(body)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + status, response = parse_cgi_output(out.getvalue()) + self.assertEqual(status, 422, response) + self.assertIn("Payment instructions must stay on-platform", response.get("error", "")) + + def test_application_rejects_payment_circumvention_and_unsafe_portfolio_url(self): + db = self.module.get_db() + token = "tok-worker" + try: + db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (1,'employer@example.com','x','Employer')") + db.execute("INSERT INTO employer_profiles (user_id) VALUES (1)") + db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (2,'worker@example.com','x','Worker')") + db.execute("INSERT INTO worker_profiles (user_id) VALUES (2)") + db.execute("INSERT INTO sessions (user_id,token,expires_at) VALUES (2,?,datetime('now','+1 day'))", [token]) + db.execute("INSERT INTO jobs (id,employer_id,title,description,category,budget_type,budget_amount,status) VALUES (7,1,'QA Job','Clean scope','testing','fixed',25,'open')") + db.commit() + finally: + db.close() + + payload = {"cover_message": "I can help. Pay me via PayPal or Zelle.", "portfolio_url": "javascript:alert(document.domain)"} + body = json.dumps(payload) + self.module._request_ctx.request_method = "POST" + self.module._request_ctx.path_info = "/api/v1/jobs/7/apply" + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = f"Bearer {token}" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = body + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(body)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + status, response = parse_cgi_output(out.getvalue()) + self.assertEqual(status, 422, response) + self.assertIn("Payment instructions must stay on-platform", response.get("error", "")) + + payload = {"cover_message": "I can help with this QA task.", "portfolio_url": "javascript:alert(document.domain)"} + for attr in ("body_cache", "raw_body"): + if hasattr(self.module._request_ctx, attr): + delattr(self.module._request_ctx, attr) + body = json.dumps(payload) + self.module._request_ctx.request_method = "POST" + self.module._request_ctx.path_info = "/api/v1/jobs/7/apply" + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = f"Bearer {token}" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = body + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(body)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + status, response = parse_cgi_output(out.getvalue()) + self.assertEqual(status, 422, response) + self.assertIn("portfolio_url must be a valid http(s) URL", response.get("error", "")) + def test_job_creation_notifies_matching_service_workers(self): db = self.module.get_db() token = "tok-employer" @@ -673,6 +759,8 @@ def test_phase2_ui_flow_polish_invariants(self): "Keep payments on-platform", "You can apply to jobs before connecting payouts.", "grid-template-columns:repeat(auto-fit,minmax(240px,1fr))", + "function safeExternalHref(value)", + "safeExternalHref(a.portfolio_url)", ] missing = [snippet for snippet in required if snippet not in text] self.assertEqual(missing, []) @@ -1547,6 +1635,9 @@ def test_phase5_proof_pack_conversion_layer_exists(self): self.assertEqual(missing, []) for rel in ["frontend/index.html", "frontend/starter-offers.html", "frontend/pricing.html", "frontend/sitemap.xml"]: self.assertIn("proof-packs.html", (REPO_ROOT / rel).read_text(encoding="utf-8", errors="ignore"), rel) + starter = (REPO_ROOT / "frontend/starter-offers.html").read_text(encoding="utf-8", errors="ignore") + for snippet in ["Clay/GTM QA Sprint", "Draft Clay/GTM QA sprint", "clay_gtm_qa_sprint"]: + self.assertIn(snippet, starter) vercel = json.loads((REPO_ROOT / "frontend/vercel.json").read_text(encoding="utf-8")) redirects = {(r.get("source"), r.get("destination")) for r in vercel.get("redirects", [])} self.assertIn(("/proof-packs", "/proof-packs.html"), redirects) diff --git a/frontend/index.html b/frontend/index.html index 972459e..ec2dbaa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -492,6 +492,14 @@

Common agent-delegated work

// ─── Helpers ───────────────────────────────────────────────── function esc(s) { return s == null ? '' : String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } +function safeExternalHref(value) { + try { + const url = new URL(String(value || '').trim(), window.location.origin); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch { + return ''; + } +} function formatCategory(cat) { return (cat || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } function safeParseJSON(str, fallback = []) { try { return JSON.parse(str || JSON.stringify(fallback)); } catch { return fallback; } @@ -2993,7 +3001,7 @@

${esc(job.title)}

${statusBadge(a.status||'pending')}

${esc(a.cover_message||'')}

- ${a.portfolio_url ? `View Portfolio →` : ''} + ${safeExternalHref(a.portfolio_url) ? `View Portfolio →` : ''}
${a.status === 'pending' ? `` : ''} diff --git a/frontend/starter-offers.html b/frontend/starter-offers.html index 2496b32..445db9c 100644 --- a/frontend/starter-offers.html +++ b/frontend/starter-offers.html @@ -72,6 +72,19 @@

Automation QA Sprint

Draft automation QA sprint

+
+ For Clay/GTM teams +

Clay/GTM QA Sprint

+
$199
+

Human spot-checking for AI-enriched account rows, source accuracy, personalization claims, broken links, duplicates, and risky outbound copy before campaigns go live.

+ +

Draft Clay/GTM QA sprint

+
For offline/manual facts

Real-World Check

diff --git a/frontend/style.css b/frontend/style.css index 40d64db..28b2b55 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1920,7 +1920,7 @@ } .lp-hiw-visual-card p { font-size: var(--text-sm); - color: var(--color-text-muted); + color: #595650; line-height: 1.6; margin: 0; } diff --git a/frontend/tests/browser-regression.spec.js b/frontend/tests/browser-regression.spec.js index d7c0af6..107604a 100644 --- a/frontend/tests/browser-regression.spec.js +++ b/frontend/tests/browser-regression.spec.js @@ -42,7 +42,8 @@ test.describe('GoHireHumans public/browser regression suite', () => { test(`${route.path} renders, has no serious axe violations, and is console-clean`, async ({ page }) => { const messages = await collectConsole(page); await setupDeterministicLocalPage(page); - const response = await page.goto(route.path, { waitUntil: 'networkidle' }); + const response = await page.goto(route.path, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); expect(response.status(), route.path).toBeLessThan(400); await expect(page.locator('body')).toContainText(route.mustContain); const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).exclude('iframe').analyze(); @@ -63,7 +64,8 @@ test.describe('GoHireHumans public/browser regression suite', () => { await setupDeterministicLocalPage(page); await page.goto('/pricing.html'); await expect(page.locator('body')).toContainText('Compare Fees'); - await page.goto('/#/services', { waitUntil: 'networkidle' }); + await page.goto('/#/services', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); await expect(page.locator('#services-result-count')).toBeVisible(); await expect(page.locator('text=Filter services')).toBeVisible(); });