Remove ticket subjects from SDP queries and the page
All checks were successful
Build and push image / build (push) Successful in 15s

Subjects can name students or describe their situation (FERPA). The
closed feed and unassigned queries no longer request subject, the
returned objects never include it, and feed cards show the ticket
number instead. SECURITY.md, NOTES.md and the README are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-16 09:24:52 -04:00
parent 3e41565ac1
commit d5a1080185
5 changed files with 13 additions and 20 deletions

View File

@ -142,7 +142,7 @@ If a query fails, a red error box shows what SDP returned, and the other section
### Unassigned verification list
`src/public/index.html` still has a hidden table listing every unassigned request by ID and subject. It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
`src/public/index.html` still has a hidden table listing every unassigned request by ID, status, site and group (no subjects; see `docs/SECURITY.md`). It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
## How it works

View File

@ -41,6 +41,7 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 has this gap. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
- **2026-10-15:** **`/api/names` makes the caller prove SDP access first** (a one-row requests query with their token) before returning the shared cache, so the names never go to a token SDP refuses.
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.

View File

@ -43,8 +43,9 @@ A request has to pass every layer below, in order, before any MCCS data is invol
### 5. The web page
- The page never receives Zoho tokens. It only receives ticket summaries (number, subject, technician, status and times) and counts.
- Everything shown on the page that came from SDP or from saved settings is HTML-escaped, so a ticket subject or a technician name can't inject script into the page.
- The page never receives Zoho tokens. It only receives ticket summaries (number, technician, status, site, group and times) and counts.
- **No ticket subjects or descriptions.** Subjects can name students or describe their situation, which may be covered by FERPA. The dashboard never asks SDP for the subject or any other free-text field, and if SDP sends one anyway, the server drops it before replying, so subjects never reach the page or the logs. (Removed on 2026-10-16; v2.1.0 and earlier showed subjects in the closed feed.)
- Everything shown on the page that came from SDP or from saved settings is HTML-escaped, so a technician, site or group name can't inject script into the page.
- Response headers block other sites from framing the dashboard (`X-Frame-Options: DENY`, against clickjacking), stop browsers guessing file types (`X-Content-Type-Options: nosniff`), and keep the dashboard's address out of requests to other sites (`Referrer-Policy: same-origin`).
- Changing settings only accepts JSON requests, which a malicious site can't send cross-site with the person's cookie.
@ -92,7 +93,7 @@ These are the boundaries of the protections above:
These haven't been checked yet. They are listed roughly by importance, and each should be marked done here, with the date and outcome, once it has been.
1. **Policy approval for where data is hosted.** Ticket subjects can name students or describe their situation, so they may be covered by FERPA. Ticket data passes through a VPS at RackNerd, administered by Skylar Grant rather than MCCS IT. Get written approval from MCCS IT security for the hosting arrangement and for who has administrative access to the server.
1. **Policy approval for where data is hosted.** Ticket data passes through a VPS at RackNerd, administered by Skylar Grant rather than MCCS IT. Get written approval from MCCS IT security for the hosting arrangement and for who has administrative access to the server. *2026-10-16: ticket subjects, the main FERPA concern, were removed (see section 5). What still passes through is ticket numbers, staff names, status, site, group and times, plus staff email and IP addresses in the logs. Approval is still needed, but the request is now for staff and operational data only.*
2. **What a non-technician sees.** Until the technician check is built, test with a non-IT staff account at an allowed domain and record exactly what the dashboard shows them.
3. **Who shares the allowed IP addresses.** Ask the network team what traffic leaves through the allowed address blocks. If campus or guest Wi-Fi does, the network layer protects less than it appears to, and sign-in becomes the main barrier.
4. **Paths around Nginx Proxy Manager.** Check that:
@ -123,6 +124,7 @@ These haven't been checked yet. They are listed roughly by importance, and each
- Live, on 2026-10-15: sign-in, sign-out, live data, and the IP access list (an MCCS address allowed; mobile data refused with 403).
- With SDP and Zoho simulated: forged and missing sign-in state, refused and failed sign-ins, tampered cookies, expired sessions, revoked tokens, SDP rejecting a token, tokens never appearing in page responses, settings validation, outside and lookalike email domains, older cookies from outside accounts, and name suggestions refused when SDP refuses the caller.
- On 2026-10-16, with SDP simulated to return a subject it wasn't asked for: neither query requested `subject`, and nothing the server returned contained it.
## Reporting a problem

View File

@ -408,17 +408,11 @@ tr.flash td { animation: flash 2.4s ease-out; }
.body { padding: 12px 14px 12px 4px; min-width: 0; }
.subject {
.ticket-id {
margin: 0;
font-size: 14px;
font-weight: 500;
overflow-wrap: anywhere;
}
.subject span {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
color: var(--muted);
margin-right: 8px;
}
.byline {
@ -517,7 +511,6 @@ body > footer p + p { margin-top: 4px; }
.verify[open] summary { border-bottom: 1px dashed var(--rule); }
.verify .warn { padding: 10px 18px 0; font-size: 13px; color: var(--alert); }
.verify td, .verify th { text-align: left; }
.verify td.subject-cell { white-space: normal; min-width: 260px; }
/* ---- settings ---- */
@ -1052,7 +1045,6 @@ function renderUnassignedList(un) {
const rows = (un.tickets || []).map(t => `<tr>
<td class="num">#${esc(t.id)}</td>
<td class="subject-cell">${esc(t.subject || 'No subject')}</td>
<td>${esc(t.status || '')}</td>
<td>${esc(t.site || '')}</td>
<td>${esc(t.group || '')}</td>
@ -1068,7 +1060,7 @@ function renderUnassignedList(un) {
${warn}
${rows
? `<div class="table-scroll"><table>
<thead><tr><th>ID</th><th>Subject</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
<thead><tr><th>ID</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`
: '<div class="table-empty">No unassigned requests.</div>'}
@ -1099,7 +1091,7 @@ function renderTicket(ticket) {
<small>${esc(e.label)}</small>
</div>
<div class="body">
<p class="subject"><span>#${esc(ticket.id)}</span>${esc(ticket.subject || 'No subject')}</p>
<p class="ticket-id">#${esc(ticket.id)}</p>
<p class="byline"><b>${esc(ticket.technician)}</b> &middot; closed ${esc(ticket.closedDisplay || clockTime(ticket.closedAt))}</p>
</div>
</article>`;

View File

@ -112,9 +112,9 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
const data = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
// never ask for subject: it can name students (FERPA), so it must not leave SDP
fields_required: [
'display_id',
'subject',
'status',
'completed_time',
'resolved_time',
@ -154,7 +154,6 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
tickets.set(String(ticket.display_id), {
id: ticket.display_id,
subject: ticket.subject,
technician,
status: statusName || null,
closedAt,
@ -280,7 +279,7 @@ async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scop
const result = await getAllRequests(accessToken, {
sort_field: 'created_time',
sort_order: 'desc',
fields_required: ['display_id', 'subject', 'status', 'technician', 'site', 'group', 'created_time'],
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'created_time'],
search_criteria: allOf(
scopeCriteria(scope),
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
@ -293,7 +292,6 @@ async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scop
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
.map(t => ({
id: t.display_id,
subject: t.subject,
status: t.status?.name || null,
site: t.site?.name || null,
group: t.group?.name || null,