Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
62 changes: 55 additions & 7 deletions backend/api_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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']]
Expand All @@ -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

Expand Down
91 changes: 91 additions & 0 deletions backend/test_deep_audit_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, [])
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,14 @@ <h2>Common agent-delegated work</h2>

// ─── Helpers ─────────────────────────────────────────────────
function esc(s) { return s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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; }
Expand Down Expand Up @@ -2993,7 +3001,7 @@ <h1 class="page-title" style="margin-top:var(--space-4)">${esc(job.title)}</h1>
${statusBadge(a.status||'pending')}
</div>
<p style="font-size:var(--text-sm);color:var(--color-text-muted);line-height:1.6;margin-bottom:var(--space-3)">${esc(a.cover_message||'')}</p>
${a.portfolio_url ? `<a href="${esc(a.portfolio_url)}" target="_blank" rel="noopener" style="font-size:var(--text-xs);color:var(--color-primary)">View Portfolio →</a>` : ''}
${safeExternalHref(a.portfolio_url) ? `<a href="${esc(safeExternalHref(a.portfolio_url))}" target="_blank" rel="noopener" style="font-size:var(--text-xs);color:var(--color-primary)">View Portfolio →</a>` : ''}
</div>
<div style="flex-shrink:0">
${a.status === 'pending' ? `<button class="btn btn-primary btn-sm" onclick="handleHire(${id},${a.id},${a.worker_id})">Hire</button>` : ''}
Expand Down
13 changes: 13 additions & 0 deletions frontend/starter-offers.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ <h3>Automation QA Sprint</h3>
</ul>
<p><a class="cta" href="/#/post-job?template=automation_verification" onclick="trackGHH('starter_offer_draft_click',{offer:'automation_qa_sprint'});trackGHH('generate_lead',{lead_type:'founding_offer',offer:'automation_qa_sprint'})">Draft automation QA sprint</a></p>
</article>
<article class="card">
<span class="pill">For Clay/GTM teams</span>
<h3>Clay/GTM QA Sprint</h3>
<div class="price">$199</div>
<p>Human spot-checking for AI-enriched account rows, source accuracy, personalization claims, broken links, duplicates, and risky outbound copy before campaigns go live.</p>
<ul class="checklist">
<li>Review 25–50 rows or a similarly bounded sample.</li>
<li>Flag hallucinated claims, unverifiable sources, stale/broken links, duplicates, and ICP-fit risks.</li>
<li>Row-level issue table with severity, evidence, and recommendation.</li>
<li>Founder-reviewed proof pack while the program is early.</li>
</ul>
<p><a class="cta" href="/#/post-job?template=clay_gtm_qa" onclick="trackGHH('starter_offer_draft_click',{offer:'clay_gtm_qa_sprint'});trackGHH('generate_lead',{lead_type:'founding_offer',offer:'clay_gtm_qa_sprint'})">Draft Clay/GTM QA sprint</a></p>
</article>
<article class="card">
<span class="pill">For offline/manual facts</span>
<h3>Real-World Check</h3>
Expand Down
2 changes: 1 addition & 1 deletion frontend/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 4 additions & 2 deletions frontend/tests/browser-regression.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});
Expand Down
Loading