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
6 changes: 5 additions & 1 deletion backend/app/auth/magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ def read_magic_token(token: str) -> str | None:
async def send_magic_email(*, to: str, link: str) -> None:
"""Email the sign-in link via Resend. Without a key (dev), log the link instead of sending."""
if not settings.resend_api_key:
log.warning("RESEND_API_KEY unset — magic link for %s: %s", to, link)
log.warning(
"RESEND_API_KEY unset — magic link for %s: %s",
to.replace("\r", "").replace("\n", ""),
link.replace("\r", "").replace("\n", ""),
)
return
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
Expand Down
26 changes: 22 additions & 4 deletions backend/app/persistence/application_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ async def apply(
# serializes the two, so this is the SQLite/no-advisory-lock backstop.)
await session.rollback()
raise AlreadyApplied(cv_id) from exc
log.info("application created: %s cv=%s ws=%s", application.id, cv_id, workspace_id)
log.info(
"application created: %s cv=%s ws=%s",
application.id.replace("\r", "").replace("\n", ""),
cv_id.replace("\r", "").replace("\n", ""),
workspace_id.replace("\r", "").replace("\n", ""),
)
return application


Expand Down Expand Up @@ -235,7 +240,11 @@ async def update_stage(
row.application = application.model_dump(mode="json", by_alias=True)
await session.commit()
log.info(
"application stage change: %s %s -> %s ws=%s", application_id, prev, stage, workspace_id
"application stage change: %s %s -> %s ws=%s",
application_id.replace("\r", "").replace("\n", ""),
prev.replace("\r", "").replace("\n", ""),
stage.replace("\r", "").replace("\n", ""),
workspace_id.replace("\r", "").replace("\n", ""),
)
return application

Expand All @@ -257,7 +266,11 @@ async def update_recruiter(
application.recruiter_email = recruiter_email
row.application = application.model_dump(mode="json", by_alias=True)
await session.commit()
log.info("application recruiter updated: %s ws=%s", application_id, workspace_id)
log.info(
"application recruiter updated: %s ws=%s",
application_id.replace("\r", "").replace("\n", ""),
workspace_id.replace("\r", "").replace("\n", ""),
)
return application


Expand Down Expand Up @@ -287,4 +300,9 @@ async def delete_application(session: AsyncSession, workspace_id: str, applicati
# in the SAME commit under the lock. delete_review rides this transaction (no inner commit).
await review_store.delete_review(session, workspace_id, cv_id)
await session.commit()
log.info("application deleted: %s cv=%s ws=%s", application_id, cv_id, workspace_id)
log.info(
"application deleted: %s cv=%s ws=%s",
application_id.replace("\r", "").replace("\n", ""),
cv_id.replace("\r", "").replace("\n", ""),
workspace_id.replace("\r", "").replace("\n", ""),
)
8 changes: 6 additions & 2 deletions backend/app/routes/cv.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,10 @@ async def read_review(
# orphan row). Never serve a review for a CV that no longer exists — self-heal by deleting
# the orphan and reading as absent.
if not await cv_store.cv_exists(session, workspace_id, cv_id):
log.warning("review: orphan review row for deleted cv=%s; removing", cv_id)
log.warning(
"review: orphan review row for deleted cv=%s; removing",
cv_id.replace("\r", "").replace("\n", ""),
)
await review_store.delete_review(session, workspace_id, cv_id)
await session.commit()
return None
Expand All @@ -435,7 +438,8 @@ async def read_review(
)
except ValidationError:
log.warning(
"review: stored review for cv=%s no longer validates; treating as absent", cv_id
"review: stored review for cv=%s no longer validates; treating as absent",
cv_id.replace("\r", "").replace("\n", ""),
)
return None

Expand Down
22 changes: 21 additions & 1 deletion backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

from __future__ import annotations

import asyncio

import pytest
from app.auth.allowlist import is_allowlisted
from app.auth.magic import make_magic_token, read_magic_token
from app.auth.magic import make_magic_token, read_magic_token, send_magic_email
from app.auth.session import get_session_user, login_user
from app.config import settings
from app.main import app
Expand All @@ -32,6 +34,24 @@ def test_magic_token_roundtrip() -> None:
assert read_magic_token("not-a-real-token") is None


def test_dev_magic_link_log_strips_line_breaks(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(settings, "resend_api_key", "")
with caplog.at_level("WARNING", logger="contender.auth"):
asyncio.run(
send_magic_email(
to="owner@example.com\r\nforged-recipient",
link="https://example.test/magic\r\nforged-entry",
)
)
message = caplog.records[-1].getMessage()
assert "\r" not in message
assert "\n" not in message
assert "forged-recipient" in message
assert "forged-entry" in message


def test_me_requires_session() -> None:
client = TestClient(app, base_url="https://testserver")
assert client.get("/auth/me").status_code == 401
Expand Down
10 changes: 10 additions & 0 deletions docs/agent-guidance-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
Durable changes to `AGENTS.md`, nested agent guidance, `CLAUDE.md`, repository scripts, and GitHub
workflows are recorded here. The local `guidance-self-update` rule enforces that contract.

## 2026-07-23 (CodeQL alert remediation)

- Replaced the doc-impact checker's shell command strings with argument-vector `git` subprocesses
and rejected option-like refs before Git sees them.
- Kept the security-extended CodeQL workflow strict: the first baseline alerts are fixed in source
rather than hidden by weakening the query suite or dismissing actionable results.

Why for future agents: pass subprocess arguments as arrays, keep executable bootstrap strings
static, and remove line breaks from user-derived values before writing them to plain-text logs.

## 2026-07-23 (public repository health hardening)

- Added the missing CodeQL workflow for Python and JavaScript/TypeScript so the active `main`
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ renders a clear *needs-sign-off blocked state* instead of the document — so a
exported, only previewed (via the un-gated `GET /workspaces/{ws}/cv/{id}` preview bundle). An
unreachable backend (bad `BACKEND_INTERNAL_URL` / ECONNREFUSED) is caught and surfaced as a clean
`502` (mirroring `lib/proxy.ts`), so the export routes fail consistently rather than throwing a 500.
Its diagnostic uses a constant format string and strips line separators from the workspace, CV, and
error fields; the forwarded session cookie is never logged.

**Synthetic sample only.** `frontend/src/render/fixtures.ts` and its tests use a clearly fictional
candidate, `example.com` links, an invalid example country code, and synthetic contact details. The
Expand Down
11 changes: 10 additions & 1 deletion docs/architecture/security.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Security

Status: active — seed doc
Last updated: 2026-07-22
Last updated: 2026-07-23

Update on any change under `backend/app/auth/` (`auth-change`) or workspace/ownership/isolation
queries under `backend/app/persistence/` (`tenancy-change`).
Expand Down Expand Up @@ -61,6 +61,15 @@ invariant 8.
only). Add allow **and** deny tests for any auth change (`backend/tests/test_auth.py`,
`test_admin.py`).

## Diagnostic logging

Plain-text logs never receive raw line breaks from request-derived fields. The development-only
magic-link fallback, CV review diagnostics, and application lifecycle logs strip CR/LF from email,
URL, workspace, CV, application, and stage values before interpolation, preventing a caller from
forging additional log entries. The renderer follows the same rule for upstream-fetch diagnostics:
its format string is constant, every field removes line separators, and the session cookie is never
logged. `test_auth.py` and `render/fetch.test.ts` pin those boundaries.

## Tenant isolation (invariant 8)

Isolation is load-bearing even single-user: a member of workspace A must never read or write
Expand Down
12 changes: 10 additions & 2 deletions docs/testing/strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ silently ignored. A skipped check is a gap, not a pass. `--fail-on-gap` turns ga

## Test inventory

- `scripts/check-doc-impact.test.mjs` — the doc-impact harness's pure rules and scratch-repository
end-to-end modes, including rejection of option-like refs before the argument-vector Git
subprocess runs. No shell command strings or network.
- `backend/tests/test_schema_roundtrip.py` — camelCase round-trip + the provenance/sign-off gate.
- `backend/tests/test_app.py` — health + the OpenAPI codegen-anchor guard (every model is emitted).
- `frontend/src/slop/heuristics.test.ts` — the deterministic AI-language detector, anchored by id.
Expand Down Expand Up @@ -103,7 +106,8 @@ silently ignored. A skipped check is a gap, not a pass. `--fail-on-gap` turns ga
magic callback logs an allowlisted address in and rejects others, **live revocation**
(de-allowlist mid-session → next request exactly **403**), the **legacy-cookie back-compat**
(a stored `role` key is ignored; `login_user` writes the legacy-compatible value for rollback
safety), and `/auth/entra/login` returns 503 while M365 is unconfigured. Runs offline (no Resend/Entra calls). Auth deps:
safety), the development magic-link log strips attacker-controlled CR/LF, and `/auth/entra/login`
returns 503 while M365 is unconfigured. Runs offline (no Resend/Entra calls). Auth deps:
`authlib`, `itsdangerous`, `httpx` (`backend/pyproject.toml`).
- `backend/tests/test_logging_config.py` — the `JsonFormatter` (`app/logging_config.py`): JSON output
carries `level`/`message`/`logger`/`time`, exception records keep the rendered traceback, and
Expand Down Expand Up @@ -353,7 +357,11 @@ silently ignored. A skipped check is a gap, not a pass. `--fail-on-gap` turns ga
/ empty list → not ready.
- `frontend/src/render/fetch.test.ts` — `getRenderBundle`: maps a 200 export to a `RenderInput` while
forwarding the session cookie, maps a 409 to a blocked result carrying the blocking bullet ids, and
relays other non-200s. Offline with a stubbed `global.fetch`.
relays other non-200s. The unreachable-backend case returns 502 and proves every constant-format
diagnostic field is line-break-free. Offline with a stubbed `global.fetch`.
- `frontend/src/lib/bootstrap-scripts.test.ts` — pins the theme and sidebar pre-paint scripts'
static storage-key literals to `THEME_KEY` / `RAIL_KEY`, so executable bootstrap source stays
interpolation-free without allowing the exported constants to drift.
- `frontend/src/app/api/render/pdf/route.test.ts` — the PDF auth handoff: after the gate precheck the
route **injects the caller's session cookie into the Playwright context**, and a 409 from the gate
is relayed **without launching Chromium**. Playwright is mocked — offline.
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/lib/bootstrap-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { RAIL_KEY, railInitScript } from "./sidebar";
import { THEME_KEY, themeInitScript } from "./theme";

describe("pre-paint bootstrap scripts", () => {
it("keeps the static theme storage key aligned with THEME_KEY", () => {
expect(themeInitScript).toContain(`localStorage.getItem('${THEME_KEY}')`);
});

it("keeps the static rail storage key aligned with RAIL_KEY", () => {
expect(railInitScript).toContain(`localStorage.getItem('${RAIL_KEY}')`);
});
});
7 changes: 4 additions & 3 deletions frontend/src/lib/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type RailState = "expanded" | "collapsed";

export const RAIL_KEY = "contender-rail";

export const railInitScript = `(function(){try{var r=localStorage.getItem(${JSON.stringify(
RAIL_KEY,
)});document.documentElement.dataset.rail=(r==='collapsed')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.rail='expanded';}})();`;
// Static source by design: never interpolate runtime data into executable bootstrap code.
// bootstrap-scripts.test.ts pins the duplicated storage-key literal to RAIL_KEY.
export const railInitScript =
"(function(){try{var r=localStorage.getItem('contender-rail');document.documentElement.dataset.rail=(r==='collapsed')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.rail='expanded';}})();";
7 changes: 4 additions & 3 deletions frontend/src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type ThemeName = "dark" | "light";

export const THEME_KEY = "contender-theme";

export const themeInitScript = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
THEME_KEY,
)});if(t!=='light'&&t!=='dark'){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='dark';}})();`;
// Static source by design: never interpolate runtime data into executable bootstrap code.
// bootstrap-scripts.test.ts pins the duplicated storage-key literal to THEME_KEY.
export const themeInitScript =
"(function(){try{var t=localStorage.getItem('contender-theme');if(t!=='light'&&t!=='dark'){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='dark';}})();";
15 changes: 11 additions & 4 deletions frontend/src/render/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,22 @@ describe("getRenderBundle", () => {
});

it("returns a clean 502 (not a throw) when the backend is unreachable", async () => {
const cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
const cause = Object.assign(new Error("connect\nECONNREFUSED"), { code: "ECONNREFUSED" });
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(Object.assign(new TypeError("fetch failed"), { cause }))),
vi.fn(() => Promise.reject(Object.assign(new TypeError("fetch\r\nfailed"), { cause }))),
);
vi.spyOn(console, "error").mockImplementation(() => {});
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});

const result = await getRenderBundle("cv-123", "session=abc");
const result = await getRenderBundle("cv-\nforged", "session=abc", "ws-\r\nforged");

expect(result).toEqual({ ok: false, status: 502, blockedBulletIds: [] });
expect(errorLog).toHaveBeenCalledOnce();
expect(errorLog.mock.calls[0]?.[0]).toBe(
"render fetch: upstream export failed workspace=%s cv=%s error=%s",
);
for (const field of errorLog.mock.calls[0] ?? []) {
expect(String(field)).not.toMatch(/[\r\n\u2028\u2029]/);
}
});
});
13 changes: 12 additions & 1 deletion frontend/src/render/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export type RenderFetch =
| { ok: true; input: RenderInput }
| { ok: false; status: number; blockedBulletIds: string[] };

function logField(value: unknown): string {
return String(value)
.replace(/\n/g, "")
.replace(/[\r\u2028\u2029]/g, "");
}

/**
* Fetch {master, cv} for `cvId`, forwarding `cookieHeader` for auth. `ok:false` carries the upstream
* status (409 = needs sign-off, with the blocking bullet ids; 401/403/404 otherwise) so callers can
Expand All @@ -44,7 +50,12 @@ export async function getRenderBundle(
} catch (err) {
// Upstream connection failure (backend down/unreachable, ECONNREFUSED, DNS). Log the workspace +
// cv id only — never the cookie. Don't let it bubble as a Next 500; relay a clean 502.
console.error(`render fetch: upstream export ${workspaceId}/${cvId} failed`, err);
console.error(
"render fetch: upstream export failed workspace=%s cv=%s error=%s",
logField(workspaceId),
logField(cvId),
logField(err),
);
return { ok: false, status: 502, blockedBulletIds: [] };
}

Expand Down
Loading