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
82 changes: 82 additions & 0 deletions frontend/src/config/__tests__/websiteSecurityTxt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

/**
* RFC 9116 makes `Expires` mandatory, and a security.txt past that date is
* *invalid* — not merely stale. It keeps serving 200 while every scanner and
* researcher tooling treats it as unusable, so the failure is silent and the
* file ends up worse than not shipping one at all.
*
* website/ has no build step and the repo has no scheduled workflow, so nothing
* else would ever notice the date passing. These assertions are the alarm: they
* fail loudly while there is still plenty of time to act.
*
* To renew: bump `Expires` in website/.well-known/security.txt to one year out
* and re-check that the Contact URL still accepts reports.
*/
const here = path.dirname(fileURLToPath(import.meta.url));
const securityTxt = path.resolve(here, '../../../../website/.well-known/security.txt');

const DAY_MS = 24 * 60 * 60 * 1000;
const WARN_WITHIN_DAYS = 90;
const FAIL_WITHIN_DAYS = 30;

const fields = (): Record<string, string> => {
const out: Record<string, string> = {};
for (const line of readFileSync(securityTxt, 'utf8').split('\n')) {
const m = line.match(/^([A-Za-z-]+):\s*(.+?)\s*$/);
if (m) out[m[1]!] = m[2]!;
}
return out;
};

const daysUntilExpiry = (): number =>
Math.floor((new Date(fields().Expires!).getTime() - Date.now()) / DAY_MS);

describe('website security.txt', () => {
it('carries the fields RFC 9116 requires', () => {
const f = fields();
expect(f.Contact, 'Contact is mandatory').toBeTruthy();
expect(f.Expires, 'Expires is mandatory').toBeTruthy();
expect(new Date(f.Expires!).toString()).not.toBe('Invalid Date');
});

it('points Canonical at the path it is actually served from', () => {
expect(fields().Canonical).toBe(
'https://accessflow.bablsoft.com/.well-known/security.txt',
);
});

it('has not expired', () => {
const days = daysUntilExpiry();
expect(
days,
`security.txt EXPIRED ${Math.abs(days)} days ago — it is invalid, not just old. ` +
`Bump Expires in website/.well-known/security.txt to one year out.`,
).toBeGreaterThan(0);
});

it(`does not expire within ${FAIL_WITHIN_DAYS} days`, () => {
const days = daysUntilExpiry();
if (days <= WARN_WITHIN_DAYS && days > FAIL_WITHIN_DAYS) {
// Early nudge: still passing, but the clock is visible in CI output.
console.warn(
`[security.txt] expires in ${days} days — renew it soon ` +
`(this test starts failing at ${FAIL_WITHIN_DAYS} days).`,
);
}
expect(
days,
`security.txt expires in ${days} days. Renew it now: set Expires in ` +
`website/.well-known/security.txt to one year from today, and confirm the ` +
`Contact URL still accepts reports.`,
).toBeGreaterThan(FAIL_WITHIN_DAYS);
});

it('does not claim validity more than a year out', () => {
// RFC 9116 §2.5.5 recommends under a year; scanners flag longer windows.
expect(daysUntilExpiry()).toBeLessThanOrEqual(366);
});
});
41 changes: 29 additions & 12 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,21 +235,38 @@ maintainer is the strongest version of this signal — security reviewers look f
### security.txt

`.well-known/security.txt` (RFC 9116) points researchers at GitHub private vulnerability
reporting. Two things about it need a human:
reporting.

1. **`Expires` is mandatory and self-destructs.** It is set to `2027-08-03`. Past that date
the file is *invalid*, not merely old — a stale security.txt is a worse signal than none.
Push the date out (and re-check the contact URL still works) at least annually.
2. **Confirm it actually deploys.** `.well-known` is a dot-directory, and static hosts vary
in whether they upload hidden paths. Cloudflare's docs do not state their behaviour
either way, so after the next deploy verify:
Cloudflare Workers assets **does** upload dot-directories — verified in production, the file
returns 200 with `content-type: text/plain`. No workaround needed.

```bash
curl -sSI https://accessflow.bablsoft.com/.well-known/security.txt
```
### Renewing it

If that 404s, the file is being skipped at upload and needs a non-hidden workaround
(or an explicit include) — it is not doing anything until that returns 200.
`Expires` is mandatory under RFC 9116, and once that date passes the file is *invalid*, not
merely old: it keeps serving 200 while scanners and researcher tooling treat it as unusable.
That failure is completely silent, which makes an expired security.txt a worse signal than
none at all.

Nothing here renews it automatically, so
[`frontend/src/config/__tests__/websiteSecurityTxt.test.ts`](../frontend/src/config/__tests__/websiteSecurityTxt.test.ts)
is the alarm:

| Time to expiry | Behaviour |
|---|---|
| > 90 days | passes silently |
| 90 → 30 days | passes, prints a renewal warning in CI output |
| < 30 days | **fails CI** with the file path and what to change |
| expired | **fails CI**, reporting how many days ago |
| set > 1 year out | **fails CI** — RFC 9116 §2.5.5 recommends under a year, and this blocks "fixing" it by setting a date in 2099 |

To renew: set `Expires` to one year from today **and** confirm the `Contact` URL still
accepts reports — the date is a claim that the contact information is current, so bumping it
without checking is the thing this guard exists to discourage.

**Residual gap:** the guard only fires when CI runs. That is often enough for an active
repo, but if development goes quiet for months the date could pass unnoticed. A scheduled
workflow that opens an issue would close that gap; the repo has no cron workflows today, so
this was left as a deliberate trade rather than new machinery.

`robots.txt` allows all crawlers and points to `sitemap.xml`. `sitemap.xml` lists the
two HTML pages (`/` and `/docs/`).
Expand Down
Loading