Compare commits

..
3 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 4b5f3f0864 chore: agenternes arbejdstræer hører ikke i repoet
Release / Build and publish image (push) Failing after 9m56s
Et `git add -A` fangede .claude/worktrees/ som et indlejret repo. Det er en
agents arbejdskopi og skal ingen steder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:39:45 +02:00
Kristian WindandClaude Opus 5 052f8a43fb fix(notes): en note kunne smugle et script ind gennem et billedes alt-tekst
Indført i aften, sammen med billedvisningen, og fundet ved at læse den igen.

`escapeHtml` tager &, < og > — ikke anførselstegn, og det er rigtigt for tekst
mellem to tags, hvor et anførselstegn ikke betyder noget. `alt` er ikke tekst
mellem to tags. Det står inde i en attribut, og der lukker det første
anførselstegn den, hvorefter resten står som markup:

    !["  onerror="alert(1)](/api/v1/attachments/aaaa)

blev til et <img> med en onerror på. Adressen var i forvejen bundet til vores
eget bilagsmønster, så den var ikke vejen ind — teksten ved siden af var.

Det er ikke en teoretisk vej. En note deles ved at ligge i et projekt, så den
læses af alle, der kan læse projektet, og scriptet ville køre på vores eget
domæne med sessionscookien, hos den der åbnede noten.

En værdi i en attribut har brug for sin egen undslipning, og har nu en. Prøven
skriver noten, åbner den og ser efter, at hændelsen står som tekst i alt og ikke
som en attribut på elementet — og at billedets 404, som er selve vejen en
onerror ville blive kaldt ad, ikke kalder noget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:39:19 +02:00
Kristian WindandClaude Opus 5 dee2d5e30b fix(faerdige): en opgave lukket efter midnat lå under "I går"
Release / Build and publish image (push) Failing after 9m56s
Fundet af prøverne, som for første gang kørte forbi midnat.

Dagen blev læst som `completed_at.slice(0, 10)` — de ti første tegn af et
tidsstempel, der er i UTC. Overskriften sammenlignede med *lokal* dato. I dansk
sommertid er de to ikke den samme dag i de første to timer efter midnat, så en
opgave lukket 00.30 blev sat under "I går", indtil klokken blev to. Tyve timer i
døgnet er de ens, hvilket er derfor prøven har været grøn hver eneste gang før.
Dagen udregnes nu lokalt, som den overskrift den skal svare til.

Og prøven for invitationslinket kunne kun bestå seks dage om ugen. Den ledte
efter "males inden fredag" som én tekst, men hurtig tilføjelse læser "fredag" som
forfaldsdatoen og tager ordet ud af titlen — tilbage står "males inden" plus et
mærke, hvis ord skifter med ugedagen. Kørt på en torsdag hedder mærket "I
morgen", og så fandt den ingenting. Prøven handler om invitationer, ikke om
datoer, så den ser nu på titlen alene.

Uddraget i notelisten viste `![](/api/v1/attachments/01a0…` for hver note med et
billede i — den eneste tekst i listen, ingen havde skrevet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:17:50 +02:00
5 changed files with 94 additions and 4 deletions
+3
View File
@@ -66,3 +66,6 @@ node_modules.nosync/
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
.claude/worktrees/
+47 -1
View File
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
// Titlen alene, ikke rækken med datomærket i.
//
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
// datoer; den skal ikke vide hvilken dag det er.
await page.getByText('males inden', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
@@ -2388,3 +2396,41 @@ test('lister overlever en gemning — også i et citat, og også med numre', asy
expect(trouble).toEqual([]);
});
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
// og resten stod som markup: et img med en onerror på. En note deles gennem et
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Ondsindet\n\n![" onerror="window.__pwned = true](/api/v1/attachments/aaaabbbbccccdddd)\n'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('img')).toHaveCount(1);
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
// den vej, en onerror ville blive kaldt ad.
await page.waitForTimeout(800);
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
const alt = await ed.locator('img').getAttribute('alt');
expect(alt).toContain('onerror');
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
// andet skal stadig være stille.
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
});
+19 -1
View File
@@ -37,6 +37,16 @@ const BLOCKS = [
const escapeHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* En værdi, der skal stå inde i en attribut.
*
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
* forskellen mellem tekst og et onerror.
*/
const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
return (
@@ -51,8 +61,16 @@ function inlineToHtml(text) {
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
// den er blevet læst — og en note er det sidste sted, man vil have en
// sporingspixel. Alt andet står som den tekst, det er.
//
// `alt` skal citat-undslippes her, og det er ikke en formalitet.
// escapeHtml tager &, < og > — ikke anførselstegn, fordi den er skrevet
// til tekst mellem to tags, hvor et anførselstegn ikke betyder noget. Her
// står værdien inde i en attribut, og der lukker et anførselstegn den:
// `![" onerror="alert(1)](/api/v1/attachments/aaaa)` blev til et img med
// en onerror på. En note er delt gennem et projekt, så det ville køre på
// vores eget domæne med sessionscookien, hos den, der åbnede noten.
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
(_, alt, src) => `<img src="${src}" alt="${alt}">`)
(_, alt, src) => `<img src="${src}" alt="${attr(alt)}">`)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+20 -1
View File
@@ -35,10 +35,29 @@
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
* how anybody looking for something they just closed would describe it.
*/
/**
* Dagen en opgave blev lukket, målt hvor personen står.
*
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
*
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
*/
const localDay = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
let days = $derived.by(() => {
const groups = new Map();
for (const task of done) {
const key = (task.completed_at ?? '').slice(0, 10);
const key = localDay(task.completed_at);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(task);
}
+5 -1
View File
@@ -113,6 +113,10 @@
*/
function plain(body) {
return (body ?? '')
// Et billede har ingen ord i sig. Uden det her stod der
// "![](/api/v1/attachments/01a0…" i uddraget af hver note med et foto i,
// hvilket er den eneste tekst i listen, ingen har skrevet.
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
@@ -447,7 +451,7 @@
border: 0;
background: none;
padding: 2px var(--s1);
max-width: 11ch;
max-width: 13ch;
}
h1 {