Security and responsible disclosure
- Last updated
- Sections
- 8
- Unresolved
- 17 marked in the text
This is a draft. Neither a lawyer nor a security assessor has read it. Every decision still outstanding is marked in the text, and counted at the top of this page.
On this page — 8 sections
In plain language
Crossing is not deployed yet. It runs on the machine of whoever is running it, as one SQLite file. We are not holding your data on a server today, because there is no server.
What is already built: passwords hashed with scrypt, session cookies that are signed and HttpOnly, an origin check on every write, rate limits that follow the account being attacked rather than just the IP, a hard allowlist on everything the server fetches from applicant tracking systems, a full DNS-level SSRF defence on the one place a user can hand us a URL, API keys stored only as hashes, and account deletion that really cascades.
What is not built: no security headers yet, no encryption at rest beyond your own disk, no MFA, no email verification, no audit log, no third-party pen test, no compliance certifications. Those are listed below by name, because a security page that only lists strengths is not a security page.
If you find a vulnerability, tell us: Unresolved: REVIEW: security contact address. Research done in good faith within the scope below is authorised, and we will not pursue you for it.
1Current status — read this first
Crossing is pre-launch. It runs locally: one Next.js process, one SQLite database file in the project's data directory, no hosted multi-tenant service, no third-party session, no analytics. Nothing on this page describes a production deployment, because there isn't one.
Practical consequences, stated plainly:
- The database file is as protected as the machine it sits on. There is no application-level encryption at rest. Anyone with read access to that file can read résumé text, uploaded PDF and DOCX bytes, tracker notes and cached model output. Use full-disk encryption; do not run it on a shared machine.
- The default configuration is a development configuration. The session signing secret falls back to a known development value if
RJF_SESSION_SECRETis unset; the cron secret, the demo account and the outbound user-agent contact address all have localhost defaults. Every one of those must be set before this is exposed to a network. Unresolved: REVIEW: this belongs in a launch runbook as a hard gate. - This page will be rewritten when there is a hosted service, at which point hosting, backups, log retention, key management and subprocessors all need entries here and in the privacy policy.
2What is in place
Accounts and passwords
- Passwords are hashed with scrypt (N=32768, r=8, p=1, 64-byte key) with a fresh 16-byte random salt per password, and verified with a constant-time comparison. The parameters are stored alongside the hash so they can be raised later without invalidating anyone.
- The plaintext password is never stored and never written to a log. Signup runs a strength check (length floor, a common-password list, a composition score).
- Changing your password deletes every session on the account and issues the current browser one new session. The point of a password change is ending someone else's access, so leaving their cookie valid would defeat it.
- Forgotten passwords are handled by a single-use link. The token is 32 random bytes and only its SHA-256 is stored, so a copy of the database cannot reset anyone's account. It expires after 60 minutes, is deleted the moment it is spent, and a completed reset kills every other outstanding token for that account. The request form answers identically whether or not the address has an account, so it cannot be used to find out who has one. Note where that link currently goes — §3.
Sessions
- A session id is 24 random bytes (192 bits). The cookie value is that id plus an HMAC-SHA256 signature; the database stores only the id. A stolen database row is not a usable cookie without the signing key, and a forged id fails the signature check before any database lookup.
- Cookie flags:
HttpOnly,SameSite=Lax,Securein production, path/, 30-day expiry. - Signing out deletes the server-side row as well as the cookie. Expired sessions are deleted on the next read that touches them, and can be purged in bulk by a scheduled tick.
Request-level defences
- CSRF: every state-changing request is checked for same-origin, in addition to
SameSite=Laxon the cookie. A request whoseOrigindoes not parse is refused. - Rate limiting: per-account when signed in, per-IP otherwise. Defaults are 240 requests per minute generally and 12 per minute on authentication routes.
- Per-account credential throttling: on top of the per-IP bucket, each account has a budget of 8 failed sign-ins per 15 minutes. A distributed attacker defeats any per-IP limit by spreading guesses across addresses; this limit follows the account being guessed at instead. Only rejected credentials count, and a success clears the tally, so a legitimate user is never charged for someone else's guessing.
- Honest about IPs:
x-forwarded-foris an ordinary request header that a direct caller can set to anything, so it is only trusted when the deployment explicitly declares how many of its own proxies sit in front. Otherwise every direct connection shares one bucket. One honest bucket beats an unlimited number of forged ones. - Input validation: request bodies and query strings are parsed against typed schemas; a mismatch is a 422, not a best-effort guess.
- Upload limits: résumés are capped at 10 MB, restricted to PDF, DOCX, RTF, TXT and Markdown, and parsed locally (no upload service, no third-party parser). Stored filenames are reduced to a safe basename with path traversal and control characters stripped, and every read of a stored file is ownership-checked in SQL by joining through the résumé that owns it.
- Untrusted HTML: job descriptions from boards are sanitised to an element allowlist, and links inside them are forced to
rel="nofollow noopener"withtarget="_blank".
Outbound requests (this is where a job aggregator gets hurt)
- Applicant tracking system reads go through a single fetcher with a compiled-in host allowlist — Greenhouse, Ashby, Lever, Workable, Rippling, Personio subdomains, plus two hosts used only for resolving apply URLs. A host that is not on the list throws before a socket opens, so a malformed source identifier can never become a request to an arbitrary domain. HTTPS only. Redirects are surfaced as errors rather than followed — a board that redirects us is a board whose contract changed, and we want to hear about it. 12-second timeout, 3 MB response cap.
- Every one of those calls is a read. No adapter posts an application. The only POST in the entire apply subsystem is a GraphQL query to Ashby whose variables are a board name and a posting id.
- "Add a job by URL" is the one place a user hands the server a URL, so it gets the full treatment:
http/httpsonly, no embedded credentials, default ports only, the hostname resolved up front with every returned address checked for public routability (loopback, private, link-local, CGNAT, multicast and the IPv6 equivalents all refused), the socket then pinned to the vetted address so a DNS rebind cannot move it, at most three redirects with every hop re-vetted, a 2 MB cap applied to both the compressed and decompressed stream (so a small gzip bomb cannot become a gigabyte of string), and a wall-clock deadline spanning all hops.
API keys
- Shown once at creation. Only a SHA-256 hash and a 6-character prefix are stored, so a lost key is replaced, never recovered. Keys are revocable.
- A session cookie is never accepted on the public API, and an API key can never be traded for a session. A page on another origin cannot ride a signed-in browser into API data.
- Key lookup and usage stamping happen in one indexed statement, so a revoked key cannot slip through mid-flight.
The assist token
The optional bookmarklet flow needs one unauthenticated endpoint, which is the sharpest edge in the product, so it is worth stating exactly:
- It is switched off in code today. The opt-in constant is
false, so no token is ever minted and the endpoint can only ever return 404. - When enabled: 256 bits of entropy; single use, settled in one atomic SQL statement rather than a read-then-write; a 10-minute time-to-live; and the same 404 for a spent, expired, invented or malformed token, so the endpoint cannot be used as an oracle.
- The response allows any origin but never allows credentials, the bookmarklet sends
credentials: 'omit', the endpoint's rate limiter is explicitly anonymous so it never reads a cookie, and every response — including the 404s — isno-store. - The bookmarklet itself never presses submit, never touches an iframe or captcha, never fills a field whose prepared value is unknown, and cannot attach a file (browsers do not let scripts populate a file input, which is a rule worth having).
Integrity of what goes to an employer
A tailored résumé goes in front of a real employer under a real person's name, so "the model was told not to" is not a control. A pure-code guard runs on every tailoring path, including the offline one, and rejects any rewrite that introduces a number, a proper noun, a date or a credential its source bullet does not contain. The job description is never an allowlist source.
Deletion
Account deletion requires you to type your own email address, destroys the session, and deletes the user row. Foreign keys are enabled on every database connection (SQLite defaults them off, so this was verified rather than assumed), and the cascade removes sessions, profile, résumés and their stored file bytes, saved and hidden jobs, view history, match scores, saved searches, notifications, queued mail, applications with their events, contacts, interviews, documents and tasks, subscription and usage rows, API keys, the apply profile including every demographic answer, learned answers, apply attempt receipts, and queue history. There is no soft delete and no tombstone.
3What is not in place
- No security headers. No Content-Security-Policy, no HSTS, no
X-Content-Type-Options, noX-Frame-Options/ frame-ancestors, no Referrer-Policy. These are cheap and should land before any public deployment. Unresolved: REVIEW: launch blocker. - No encryption at rest beyond the host filesystem, and no key management. See §1.
- No multi-factor authentication, no passkeys, and no email verification. An account is an email address and a password. The database has an
email_verifiedcolumn and nothing ever sets it. - The password-reset link is not delivered. The reset flow described in §2 is built and works, but no mail transport is configured, so the message carrying the link is written to a local outbox and read from a developer page instead of arriving in your inbox. Reset-by-email starts working the moment a transport is connected, and not before. Unresolved: REVIEW: connect a transport with SPF, DKIM and DMARC before launch, and confirm the reset link is built from a trusted base URL rather than a request header.
- No administrative audit log. The database has an
audit_logtable, but nothing writes to it. We will not claim an audit trail we do not have. - Some data survives account deletion. The model-response cache has no user column and no expiry, and for résumé parsing its stored response is the structured résumé; the AI guard's rejection log intentionally keeps the user id as null but retains up to 200 characters of the original bullet and the rejected rewrite. These are named in the privacy policy too. Unresolved: REVIEW: both are fixable — a cache purge on deletion and a retention window on the rejection log — and should be fixed before launch rather than disclosed forever.
- Rate limits are in-process. They live in memory, reset on restart, and do not coordinate across multiple instances.
- Session purging only runs if scheduled. Expired sessions are removed lazily on access; the bulk purge needs an operator-scheduled tick.
- No independent penetration test, no bug bounty, no SOC 2, no ISO 27001, no HECVAT, no cyber-insurance. Unresolved: REVIEW: decide which of these a first enterprise or EU customer will actually demand, and when.
- No formal incident-response plan or breach-notification runbook, which GDPR Art. 33 (72 hours) and US state breach laws effectively require once real user data is hosted. Unresolved: REVIEW: launch blocker.
4Reporting a vulnerability
Email Unresolved: REVIEW: security contact address — a dedicated security@ inbox, monitored, not a personal
address.
Please include:
- What you found and where (URL or endpoint, and the request if relevant).
- Steps to reproduce, or a minimal proof of concept.
- What an attacker could actually do with it.
- How you would like to be credited, if at all.
What you can expect from us:
- Acknowledgement within Unresolved: REVIEW: pick a number you can keep — 3 business days is a reasonable commitment for a solo operator.
- An assessment and a plan within Unresolved: REVIEW: 10 business days.
- Progress updates until it is fixed, and credit in the fix note if you want it.
- Coordinated disclosure: we ask you to hold public details until a fix ships or Unresolved: REVIEW: 90 days have passed, whichever is first. If we cannot fix it in that window we will tell you why rather than going quiet.
There is no bug bounty programme and no payment today. Unresolved: REVIEW: say so plainly, or set a small budget — an unfunded "we may reward at our discretion" is worse than an honest no.
5Safe harbour
If you make a good-faith effort to follow this policy while researching a vulnerability:
- We consider your research authorised access under the Computer Fraud and Abuse Act and equivalent computer-misuse laws, and we will not bring or support a claim against you for it.
- We will not pursue a claim under the DMCA's anti-circumvention provisions for circumventing a technical measure in the course of that research.
- We waive any Terms of Service or Acceptable Use restriction that your testing would otherwise breach, to the extent needed for the research described here.
- If a third party brings action against you for research that complied with this policy, we will make it known publicly and to them that your actions were authorised.
If you are unsure whether something is in scope or whether a particular test is acceptable, ask first — a question sent to the security address is always in good faith.
This safe harbour covers only claims we ourselves could bring. It cannot bind third parties, including employers, job boards or applicant tracking systems.
6Scope
In scope
- The Crossing web application and its API, on Unresolved: REVIEW: production domain — locally,
localhost:3000. - The public API at
/api/v1, its key handling and its plan gates. - The apply-assist endpoint and bookmarklet (note the endpoint is disabled in code today).
- Authentication, session handling, CSRF, rate limiting, authorisation between accounts.
- Server-side request forgery and the URL importer.
- Data exposure across accounts, including anything reachable through export or feed endpoints.
- The dependency chain, if you can demonstrate exploitability here rather than a version number.
Out of scope
- Employers' sites, applicant tracking systems, and the job boards we ingest from. Do not test them. They are third parties who have not authorised anything and cannot be covered by our safe harbour. Reports about their systems belong to them.
- Denial of service, volumetric testing, and anything that degrades service for other people.
- Social engineering of any person, and physical attacks.
- Spamming the outbox, signup or contact forms.
- Findings that require a compromised device, a malicious browser extension, or a person already having your credentials.
- Missing security headers, cookie flags on non-sensitive cookies, and best-practice scanner output with no demonstrated impact. §3 already lists the headers we know are missing; a report telling us again is not a finding.
- Self-XSS, clickjacking on pages with no state-changing action, and version-disclosure banners.
- Anything found on a build that is not the current one.
Rules
- Use your own test account. Never access, modify or retain another person's data.
- Stop at proof. If you reach data that is not yours, stop, do not download it, and tell us what you reached.
- Do not destroy data, degrade the service, or leave persistent test artefacts.
- Report promptly, one issue per report.
7Practical advice for people using Crossing
- Use a unique password. There is no MFA yet, and until a mail transport is connected the reset link never leaves the machine, so a forgotten password is a problem for whoever runs the install.
- Treat saved-search feed URLs like passwords: they carry a random token instead of a sign-in, so anyone with the URL can read that feed.
- Treat API keys the same way; revoke and re-issue rather than sharing.
- Run it on a machine with full-disk encryption. The database file is the whole product.
- Sign out on shared machines — that deletes the session server-side, not just the cookie.
- Remember that the employer's application page is theirs. Everything you type there is governed by their policies.
Who to ask
Security contact: Unresolved: REVIEW: address
Who "we" is: Unresolved: REVIEW: legal entity name and registered address