bug fixes, add length limits to various user provided strings

AuthorKonata <konata@posteo.jp>
Date
Commit4b37f3c80644b5784b365d4b0820d76e1fa1e39b
Parenta1baf9a
19 files changed, 465 insertions(+), 81 deletions(-)
MREADME.md
@@ -70,6 +70,10 @@ All settings are environment variables:
7070 | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
7171 | `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches or editing files through the UI |
7272 | `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | same as above but for email |
73+| `MAX_TITLE_BYTES` | `500` | Max length for titles (issues, patches, releases) |
74+| `MAX_TEXT_BODY_BYTES` | `100000` | Max length for text bodies (issue/patch descriptions, comments, release notes) |
75+| `MAX_USERNAME_BYTES` | `64` | Max length for usernames at registration |
76+| `MAX_PASSWORD_BYTES` | `1024` | Max length for passwords at registration and password change |
7377
7478 \* More workers mean more CPU cores can be used to parallelize highlighting of files.
7579 Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -95,4 +99,5 @@ bun run test # Run E2E and unit tests (don't use bun test directly, it
9599
96100 ## Roadmap
97101 - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
98-- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
102+- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
103+- remove test retry logic when bun doesn't randomly get stuck anymore
Mscripts/test.ts
@@ -1,5 +1,5 @@
11 import { readdirSync } from 'fs';
2-import { execFileSync } from 'child_process';
2+import { spawn } from 'child_process';
33 import path from 'path';
44
55 const testsDir = path.resolve('tests');
@@ -7,19 +7,61 @@ const files = readdirSync(testsDir)
77 .filter(f => f.endsWith('.test.ts'))
88 .sort();
99
10+const STALL_TIMEOUT = 20_000; // kill if no output for 20s
11+const MAX_RETRIES = 2;
12+// retry logic needed because tests get randomly get stuck on startup with bun
13+// strace shows bun completely spinning in futex and not doing anything else
14+function runTest(filePath: string): Promise<boolean> {
15+ return new Promise((resolve) => {
16+ const child = spawn('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
17+ stdio: ['ignore', 'pipe', 'pipe'],
18+ });
19+
20+ let timer = setTimeout(onStall, STALL_TIMEOUT);
21+
22+ function onStall() {
23+ console.error(`\n[test-runner] stall detected, killing ${path.basename(filePath)} (no output for ${STALL_TIMEOUT / 1000}s)`);
24+ child.kill('SIGKILL');
25+ }
26+
27+ function resetTimer() {
28+ clearTimeout(timer);
29+ timer = setTimeout(onStall, STALL_TIMEOUT);
30+ }
31+
32+ child.stdout!.on('data', (chunk: Buffer) => {
33+ process.stdout.write(chunk);
34+ resetTimer();
35+ });
36+ child.stderr!.on('data', (chunk: Buffer) => {
37+ process.stderr.write(chunk);
38+ resetTimer();
39+ });
40+
41+ child.on('close', (code) => {
42+ clearTimeout(timer);
43+ resolve(code === 0);
44+ });
45+ });
46+}
47+
1048 let passed = 0;
1149 let failed = 0;
1250
1351 for (const file of files) {
1452 const filePath = path.join(testsDir, file);
15- try {
16- execFileSync('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
17- stdio: 'inherit',
18- });
19- passed++;
20- } catch {
21- failed++;
53+ let ok = false;
54+
55+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
56+ if (attempt > 1) {
57+ console.log(`[test-runner] retrying ${file} (attempt ${attempt}/${MAX_RETRIES})`);
58+ }
59+ ok = await runTest(filePath);
60+ if (ok) break;
2261 }
62+
63+ if (ok) passed++;
64+ else failed++;
2365 }
2466
2567 console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
Msrc/config.ts
@@ -27,6 +27,10 @@ const config = {
2727 COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin",
2828 COMMITTER_EMAIL: "",
2929 EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null,
30+ MAX_TITLE_BYTES: parseInt(env.MAX_TITLE_BYTES ?? "", 10) || 500,
31+ MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000,
32+ MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64,
33+ MAX_PASSWORD_BYTES: parseInt(env.MAX_PASSWORD_BYTES ?? "", 10) || 1024,
3034 };
3135
3236 // Derived values that depend on other config fields
Asrc/db/helpers.ts
@@ -0,0 +1,28 @@
1+import type { ExpressionBuilder } from "kysely";
2+import type { Database } from "./index.ts";
3+
4+export function issuesLabelFilter(
5+ eb: ExpressionBuilder<Database, "issues">,
6+ labelIds: number[],
7+) {
8+ return eb.exists(
9+ eb
10+ .selectFrom("issue_labels")
11+ .select("issue_labels.issue_id")
12+ .whereRef("issue_labels.issue_id", "=", "issues.id")
13+ .where("issue_labels.label_id", "in", labelIds),
14+ );
15+}
16+
17+export function patchesLabelFilter(
18+ eb: ExpressionBuilder<Database, "patches">,
19+ labelIds: number[],
20+) {
21+ return eb.exists(
22+ eb
23+ .selectFrom("patch_labels")
24+ .select("patch_labels.patch_id")
25+ .whereRef("patch_labels.patch_id", "=", "patches.id")
26+ .where("patch_labels.label_id", "in", labelIds),
27+ );
28+}
Msrc/routes/auth.tsx
@@ -242,9 +242,13 @@ export const authRoutes = new Elysia()
242242 },
243243 {
244244 body: t.Object({
245- username: t.String(),
246- password: t.Optional(t.String()),
247- password2: t.Optional(t.String()),
245+ username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
246+ password: t.Optional(
247+ t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
248+ ),
249+ password2: t.Optional(
250+ t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
251+ ),
248252 application: t.Optional(t.String()),
249253 }),
250254 },
Msrc/routes/issues.tsx
@@ -1,6 +1,8 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
3+import config from "../config.ts";
34 import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
5+import { issuesLabelFilter } from "../db/helpers.ts";
46 import { db, getRepo, type LabelRow } from "../db/index.ts";
57 import {
68 requireAdmin,
@@ -57,13 +59,8 @@ export const issueRoutes = new Elysia()
5759 .select(["issues.status", db.fn.countAll<number>().as("count")])
5860 .where("issues.repo_id", "=", repo.id);
5961 if (labelIds.length > 0) {
60- countQuery = countQuery.where(({ exists, selectFrom }) =>
61- exists(
62- selectFrom("issue_labels")
63- .select("issue_labels.issue_id")
64- .whereRef("issue_labels.issue_id", "=", "issues.id")
65- .where("issue_labels.label_id", "in", labelIds),
66- ),
62+ countQuery = countQuery.where((eb) =>
63+ issuesLabelFilter(eb, labelIds),
6764 );
6865 }
6966 const allCounts = await countQuery
@@ -99,13 +96,8 @@ export const issueRoutes = new Elysia()
9996 .where("issues.repo_id", "=", repo.id)
10097 .where("issues.status", "=", status);
10198 if (labelIds.length > 0) {
102- listQuery = listQuery.where(({ exists, selectFrom }) =>
103- exists(
104- selectFrom("issue_labels")
105- .select("issue_labels.issue_id")
106- .whereRef("issue_labels.issue_id", "=", "issues.id")
107- .where("issue_labels.label_id", "in", labelIds),
108- ),
99+ listQuery = listQuery.where((eb) =>
100+ issuesLabelFilter(eb, labelIds),
109101 );
110102 }
111103 const issues = await listQuery
@@ -295,8 +287,10 @@ export const issueRoutes = new Elysia()
295287 },
296288 {
297289 body: t.Object({
298- title: t.String(),
299- body: t.Optional(t.String()),
290+ title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
291+ body: t.Optional(
292+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
293+ ),
300294 label_ids: t.Optional(
301295 t.Union([t.String(), t.Array(t.String())]),
302296 ),
@@ -474,7 +468,9 @@ export const issueRoutes = new Elysia()
474468 });
475469 },
476470 {
477- body: t.Object({ body: t.String() }),
471+ body: t.Object({
472+ body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
473+ }),
478474 },
479475 )
480476
@@ -681,8 +677,10 @@ export const issueRoutes = new Elysia()
681677 },
682678 {
683679 body: t.Object({
684- title: t.String(),
685- edit_body: t.Optional(t.String()),
680+ title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
681+ edit_body: t.Optional(
682+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
683+ ),
686684 }),
687685 },
688686 )
@@ -733,7 +731,9 @@ export const issueRoutes = new Elysia()
733731 number: t.String(),
734732 id: t.Numeric(),
735733 }),
736- body: t.Object({ edit_body: t.String() }),
734+ body: t.Object({
735+ edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
736+ }),
737737 },
738738 )
739739
Msrc/routes/patches.tsx
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
33 import config from "../config.ts";
44 import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
5+import { patchesLabelFilter } from "../db/helpers.ts";
56 import { db, getRepo, type LabelRow } from "../db/index.ts";
67 import {
78 requireAdmin,
@@ -89,17 +90,8 @@ export const patchRoutes = new Elysia()
8990 ])
9091 .where("patches.repo_id", "=", repo.id);
9192 if (labelIds.length > 0) {
92- countQuery = countQuery.where(({ exists, selectFrom }) =>
93- exists(
94- selectFrom("patch_labels")
95- .select("patch_labels.patch_id")
96- .whereRef(
97- "patch_labels.patch_id",
98- "=",
99- "patches.id",
100- )
101- .where("patch_labels.label_id", "in", labelIds),
102- ),
93+ countQuery = countQuery.where((eb) =>
94+ patchesLabelFilter(eb, labelIds),
10395 );
10496 }
10597 const allCounts = await countQuery
@@ -139,17 +131,8 @@ export const patchRoutes = new Elysia()
139131 .where("patches.repo_id", "=", repo.id)
140132 .where("patches.status", "=", status);
141133 if (labelIds.length > 0) {
142- listQuery = listQuery.where(({ exists, selectFrom }) =>
143- exists(
144- selectFrom("patch_labels")
145- .select("patch_labels.patch_id")
146- .whereRef(
147- "patch_labels.patch_id",
148- "=",
149- "patches.id",
150- )
151- .where("patch_labels.label_id", "in", labelIds),
152- ),
134+ listQuery = listQuery.where((eb) =>
135+ patchesLabelFilter(eb, labelIds),
153136 );
154137 }
155138 const patches = await listQuery
@@ -426,8 +409,12 @@ export const patchRoutes = new Elysia()
426409 },
427410 {
428411 body: t.Object({
429- title: t.Optional(t.String()),
430- description: t.Optional(t.String()),
412+ title: t.Optional(
413+ t.String({ maxLength: config.MAX_TITLE_BYTES }),
414+ ),
415+ description: t.Optional(
416+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
417+ ),
431418 patch_file: t.Optional(t.File()),
432419 label_ids: t.Optional(
433420 t.Union([t.String(), t.Array(t.String())]),
@@ -854,7 +841,9 @@ export const patchRoutes = new Elysia()
854841 });
855842 },
856843 {
857- body: t.Object({ body: t.String() }),
844+ body: t.Object({
845+ body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
846+ }),
858847 },
859848 )
860849
@@ -981,7 +970,9 @@ export const patchRoutes = new Elysia()
981970 number: t.String(),
982971 id: t.Numeric(),
983972 }),
984- body: t.Object({ edit_body: t.String() }),
973+ body: t.Object({
974+ edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
975+ }),
985976 },
986977 )
987978
@@ -1025,8 +1016,10 @@ export const patchRoutes = new Elysia()
10251016 },
10261017 {
10271018 body: t.Object({
1028- title: t.String(),
1029- edit_description: t.Optional(t.String()),
1019+ title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
1020+ edit_description: t.Optional(
1021+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
1022+ ),
10301023 }),
10311024 },
10321025 )
Msrc/routes/releases.tsx
@@ -153,12 +153,12 @@ export const releasesRoutes = new Elysia()
153153 />,
154154 );
155155 }
156- if (tagName.includes("/")) {
156+ if (!/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
157157 return html(
158158 <NewRelease
159159 user={user!}
160160 repo={repo}
161- error="Tag name must not contain slashes"
161+ error="Tag name may only contain letters, digits, dots, hyphens, underscores, and plus signs"
162162 values={formValues}
163163 />,
164164 );
@@ -359,10 +359,16 @@ export const releasesRoutes = new Elysia()
359359 {
360360 body: t.Object({
361361 create_tag: t.Optional(t.String()),
362- tag_name: t.Optional(t.String()),
362+ tag_name: t.Optional(
363+ t.String({ maxLength: config.MAX_TITLE_BYTES }),
364+ ),
363365 revision: t.Optional(t.String()),
364- name: t.Optional(t.String()),
365- notes: t.Optional(t.String()),
366+ name: t.Optional(
367+ t.String({ maxLength: config.MAX_TITLE_BYTES }),
368+ ),
369+ notes: t.Optional(
370+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
371+ ),
366372 include_source_code: t.Optional(t.String()),
367373 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
368374 }),
Msrc/routes/repos.tsx
@@ -2,6 +2,7 @@ import { readFileSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { fileTypeFromBuffer } from "file-type";
5+import { sql } from "kysely";
56 import config from "../config.ts";
67 import {
78 COMMITS_PER_PAGE,
@@ -109,6 +110,10 @@ export const repoRoutes = new Elysia()
109110
110111 const isAdmin = user?.isAdmin ?? false;
111112
113+ const searchPattern = search
114+ ? `%${search.replace(/[\\%_]/g, "\\$&")}%`
115+ : undefined;
116+
112117 const countResult = await db
113118 .selectFrom("repositories")
114119 .select(db.fn.countAll<number>().as("count"))
@@ -120,12 +125,9 @@ export const repoRoutes = new Elysia()
120125 ])
121126 : eb("is_private", "=", 0),
122127 )
123- .$if(!!search, (qb) =>
124- qb.where((eb) =>
125- eb.or([
126- eb("name", "like", `%${search}%`),
127- eb("description", "like", `%${search}%`),
128- ]),
128+ .$if(!!searchPattern, (qb) =>
129+ qb.where(
130+ sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
129131 ),
130132 )
131133 .executeTakeFirst();
@@ -148,12 +150,9 @@ export const repoRoutes = new Elysia()
148150 ])
149151 : eb("is_private", "=", 0),
150152 )
151- .$if(!!search, (qb) =>
152- qb.where((eb) =>
153- eb.or([
154- eb("name", "like", `%${search}%`),
155- eb("description", "like", `%${search}%`),
156- ]),
153+ .$if(!!searchPattern, (qb) =>
154+ qb.where(
155+ sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
157156 ),
158157 )
159158 .orderBy("is_pinned", "desc")
Msrc/routes/settings.tsx
@@ -147,9 +147,15 @@ export const settingsRoutes = new Elysia()
147147 },
148148 {
149149 body: t.Object({
150- current_password: t.Optional(t.String()),
151- new_password: t.String(),
152- confirm_password: t.String(),
150+ current_password: t.Optional(
151+ t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
152+ ),
153+ new_password: t.String({
154+ maxLength: config.MAX_PASSWORD_BYTES,
155+ }),
156+ confirm_password: t.String({
157+ maxLength: config.MAX_PASSWORD_BYTES,
158+ }),
153159 }),
154160 },
155161 )
Msrc/styles/components.css
@@ -510,6 +510,7 @@
510510 text-decoration: none;
511511 color: var(--color-text);
512512 flex: 1;
513+ word-wrap: anywhere;
513514 }
514515 .issue-title:hover {
515516 color: var(--color-link);
Msrc/views/Settings.tsx
@@ -1,3 +1,4 @@
1+import config from "../config.ts";
12 import { formatDate, formatDateTime } from "../lib/formatDate.ts";
23 import type { SessionUser } from "../middleware/session.ts";
34 import { Avatar } from "./Avatar.tsx";
@@ -177,6 +178,7 @@ export function Settings({
177178 id="current_password"
178179 name="current_password"
179180 autocomplete="current-password"
181+ maxlength={config.MAX_PASSWORD_BYTES}
180182 />
181183 </div>
182184 )}
@@ -190,6 +192,7 @@ export function Settings({
190192 id="new_password"
191193 name="new_password"
192194 autocomplete="new-password"
195+ maxlength={config.MAX_PASSWORD_BYTES}
193196 />
194197 </div>
195198 <div class="form-group">
@@ -202,6 +205,7 @@ export function Settings({
202205 id="confirm_password"
203206 name="confirm_password"
204207 autocomplete="new-password"
208+ maxlength={config.MAX_PASSWORD_BYTES}
205209 />
206210 </div>
207211 <div class="form-actions">
Msrc/views/auth/Register.tsx
@@ -1,3 +1,4 @@
1+import config from "../../config.ts";
12 import { Layout } from "../layout.tsx";
23
34 interface RegisterProps {
@@ -38,6 +39,7 @@ export function Register({ error, question, pending }: RegisterProps) {
3839 type="text"
3940 required
4041 autocomplete="username"
42+ maxlength={config.MAX_USERNAME_BYTES}
4143 pattern="[a-zA-Z0-9_-]+"
4244 title="Letters, numbers, hyphens and underscores only"
4345 />
@@ -74,6 +76,7 @@ export function Register({ error, question, pending }: RegisterProps) {
7476 type="password"
7577 autocomplete="new-password"
7678 minlength="8"
79+ maxlength={config.MAX_PASSWORD_BYTES}
7780 />
7881 </div>
7982 <div class="form-group">
@@ -84,6 +87,7 @@ export function Register({ error, question, pending }: RegisterProps) {
8487 type="password"
8588 autocomplete="new-password"
8689 minlength="8"
90+ maxlength={config.MAX_PASSWORD_BYTES}
8791 />
8892 </div>
8993 <button type="submit" class="btn btn-primary btn-block">
Msrc/views/issues/IssueDetail.tsx
@@ -1,3 +1,4 @@
1+import config from "../../config.ts";
12 import type {
23 IssueCommentRow,
34 IssueRow,
@@ -78,6 +79,7 @@ export function IssueDetail({
7879 name="title"
7980 value={issue.title}
8081 required
82+ maxlength={config.MAX_TITLE_BYTES}
8183 />
8284 <input
8385 type="hidden"
@@ -290,6 +292,7 @@ export function IssueDetail({
290292 id="edit-issue-body"
291293 name="edit_body"
292294 rows="6"
295+ maxlength={config.MAX_TEXT_BODY_BYTES}
293296 >
294297 {issue.body}
295298 </textarea>
@@ -361,6 +364,7 @@ export function IssueDetail({
361364 class="form-input"
362365 name="edit_body"
363366 rows="6"
367+ maxlength={config.MAX_TEXT_BODY_BYTES}
364368 >
365369 {comment.body}
366370 </textarea>
@@ -401,6 +405,7 @@ export function IssueDetail({
401405 <textarea
402406 name="body"
403407 rows="6"
408+ maxlength={config.MAX_TEXT_BODY_BYTES}
404409 placeholder="Leave a comment (Markdown supported)"
405410 required
406411 />
Msrc/views/issues/NewIssue.tsx
@@ -1,3 +1,4 @@
1+import config from "../../config.ts";
12 import type { LabelRow, RepositoryRow } from "../../db/index.ts";
23 import { labelTextColor } from "../../lib/labelColor.ts";
34 import type { SessionUser } from "../../middleware/session.ts";
@@ -39,6 +40,7 @@ export function NewIssue({
3940 name="title"
4041 type="text"
4142 required
43+ maxlength={config.MAX_TITLE_BYTES}
4244 placeholder="Short, descriptive title"
4345 />
4446 </div>
@@ -53,6 +55,7 @@ export function NewIssue({
5355 id="body"
5456 name="body"
5557 rows="10"
58+ maxlength={config.MAX_TEXT_BODY_BYTES}
5659 placeholder="Describe the issue..."
5760 >
5861 {template ?? ""}
Msrc/views/patches/NewPatch.tsx
@@ -1,3 +1,4 @@
1+import config from "../../config.ts";
12 import type { LabelRow, RepositoryRow } from "../../db/index.ts";
23 import { labelTextColor } from "../../lib/labelColor.ts";
34 import type { SessionUser } from "../../middleware/session.ts";
@@ -40,6 +41,7 @@ export function NewPatch({
4041 name="title"
4142 type="text"
4243 required
44+ maxlength={config.MAX_TITLE_BYTES}
4345 placeholder="What does this patch do?"
4446 />
4547 </div>
@@ -50,7 +52,7 @@ export function NewPatch({
5052 (Markdown supported, optional)
5153 </span>
5254 </label>
53- <textarea id="description" name="description" rows="5">
55+ <textarea id="description" name="description" rows="5" maxlength={config.MAX_TEXT_BODY_BYTES}>
5456 {template ?? ""}
5557 </textarea>
5658 </div>
Msrc/views/patches/PatchDetail.tsx
@@ -1,4 +1,5 @@
11 import { escapeHtml } from "@kitajs/html";
2+import config from "../../config.ts";
23 import type {
34 LabelRow,
45 PatchCommentRow,
@@ -99,6 +100,7 @@ export function PatchDetail({
99100 name="title"
100101 value={patch.title}
101102 required
103+ maxlength={config.MAX_TITLE_BYTES}
102104 />
103105 <input
104106 type="hidden"
@@ -372,6 +374,7 @@ export function PatchDetail({
372374 id="edit-patch-desc"
373375 name="edit_description"
374376 rows="6"
377+ maxlength={config.MAX_TEXT_BODY_BYTES}
375378 >
376379 {patch.description}
377380 </textarea>
@@ -471,6 +474,7 @@ export function PatchDetail({
471474 class="form-input"
472475 name="edit_body"
473476 rows="6"
477+ maxlength={config.MAX_TEXT_BODY_BYTES}
474478 >
475479 {comment.body}
476480 </textarea>
@@ -511,6 +515,7 @@ export function PatchDetail({
511515 <textarea
512516 name="body"
513517 rows="6"
518+ maxlength={config.MAX_TEXT_BODY_BYTES}
514519 placeholder="Leave a comment (Markdown supported)"
515520 required
516521 />
Msrc/views/releases/NewRelease.tsx
@@ -1,3 +1,4 @@
1+import config from "../../config.ts";
12 import type { RepositoryRow } from "../../db/index.ts";
23 import type { SessionUser } from "../../middleware/session.ts";
34 import { Layout } from "../layout.tsx";
@@ -47,6 +48,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
4748 name="name"
4849 class="form-input"
4950 required
51+ maxlength={config.MAX_TITLE_BYTES}
5052 value={values?.name ?? ""}
5153 placeholder="e.g. Version 1.0 — Initial Release"
5254 />
@@ -77,6 +79,9 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
7779 id="tag_name"
7880 name="tag_name"
7981 class="form-input"
82+ maxlength={config.MAX_TITLE_BYTES}
83+ pattern="[a-zA-Z0-9._\-+]+"
84+ title="Letters, digits, dots, hyphens, underscores, and plus signs only"
8085 value={values?.tag_name ?? ""}
8186 placeholder="v1.0.0"
8287 />
@@ -108,6 +113,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
108113 name="notes"
109114 class="form-input form-textarea"
110115 rows="8"
116+ maxlength={config.MAX_TEXT_BODY_BYTES}
111117 >
112118 {values?.notes ?? ""}
113119 </textarea>
Atests/e2e.validation.test.ts
@@ -0,0 +1,267 @@
1+/**
2+ * Tests for input validation: body size limits, username/password limits,
3+ * tag name validation, and LIKE search wildcard escaping.
4+ */
5+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
6+import {
7+ BASE,
8+ ADMIN_PASS,
9+ setupTestEnv,
10+ spawnServer,
11+ killServer,
12+ seedRepo,
13+} from './helpers.ts';
14+import config from '../src/config.ts';
15+
16+let server: Awaited<ReturnType<typeof spawnServer>>;
17+let sessionCookie = '';
18+let issueUrl = '';
19+
20+async function adminLogin(): Promise<string> {
21+ const res = await fetch(`${BASE}/login`, {
22+ method: 'POST',
23+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
24+ body: new URLSearchParams({ username: 'admin', password: ADMIN_PASS }),
25+ redirect: 'manual',
26+ });
27+ const raw = res.headers.get('set-cookie') ?? '';
28+ return raw.split(';')[0]!; // "session=<hex>"
29+}
30+
31+async function post(path: string, body: Record<string, string>): Promise<Response> {
32+ return fetch(`${BASE}${path}`, {
33+ method: 'POST',
34+ headers: {
35+ 'Content-Type': 'application/x-www-form-urlencoded',
36+ Cookie: sessionCookie,
37+ },
38+ body: new URLSearchParams(body),
39+ redirect: 'manual',
40+ });
41+}
42+
43+beforeAll(async () => {
44+ await setupTestEnv();
45+ server = await spawnServer();
46+ sessionCookie = await adminLogin();
47+
48+ // Create a repo and seed it so issues/patches can be submitted
49+ const res = await post('/new', { name: 'val-repo' });
50+ expect(res.status).toBe(302);
51+ await seedRepo('val-repo');
52+
53+ // Create a baseline issue so we have an issue URL for comment tests
54+ const issueRes = await post('/val-repo/issues', { title: 'Baseline issue', body: 'ok' });
55+ expect(issueRes.status).toBe(302);
56+ issueUrl = issueRes.headers.get('location') ?? '/val-repo/issues/1';
57+});
58+
59+afterAll(async () => {
60+ await killServer(server);
61+});
62+
63+// ─── Body size limits ─────────────────────────────────────────────────────────
64+
65+describe('body size limits', () => {
66+ test('issue body at limit is accepted', async () => {
67+ const res = await post('/val-repo/issues', {
68+ title: 'Body at limit',
69+ body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
70+ });
71+ expect(res.status).toBe(302);
72+ });
73+
74+ test('issue body over limit is rejected', async () => {
75+ const res = await post('/val-repo/issues', {
76+ title: 'Body over limit',
77+ body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
78+ });
79+ expect(res.status).toBe(422);
80+ });
81+
82+ test('issue title at limit is accepted', async () => {
83+ const res = await post('/val-repo/issues', {
84+ title: 'x'.repeat(config.MAX_TITLE_BYTES),
85+ body: 'ok',
86+ });
87+ expect(res.status).toBe(302);
88+ });
89+
90+ test('issue title over limit is rejected', async () => {
91+ const res = await post('/val-repo/issues', {
92+ title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
93+ body: 'ok',
94+ });
95+ expect(res.status).toBe(422);
96+ });
97+
98+ test('issue comment body at limit is accepted', async () => {
99+ const res = await post(`${issueUrl}/comments`, {
100+ body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
101+ });
102+ expect(res.status).toBe(302);
103+ });
104+
105+ test('issue comment body over limit is rejected', async () => {
106+ const res = await post(`${issueUrl}/comments`, {
107+ body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
108+ });
109+ expect(res.status).toBe(422);
110+ });
111+
112+ test('patch description at limit is accepted', async () => {
113+ const res = await post('/val-repo/patches', {
114+ title: 'Patch ok',
115+ description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
116+ });
117+ // No patch_file provided → will fail business logic, but schema passes → 302 or 200, not 422
118+ expect(res.status).not.toBe(422);
119+ });
120+
121+ test('patch description over limit is rejected', async () => {
122+ const res = await post('/val-repo/patches', {
123+ title: 'Patch bad',
124+ description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
125+ });
126+ expect(res.status).toBe(422);
127+ });
128+
129+ test('patch title over limit is rejected', async () => {
130+ const res = await post('/val-repo/patches', {
131+ title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
132+ });
133+ expect(res.status).toBe(422);
134+ });
135+});
136+
137+// ─── Auth limits ──────────────────────────────────────────────────────────────
138+
139+describe('auth limits', () => {
140+ test('username over limit is rejected at registration', async () => {
141+ const res = await fetch(`${BASE}/register`, {
142+ method: 'POST',
143+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
144+ body: new URLSearchParams({
145+ username: 'u'.repeat(config.MAX_USERNAME_BYTES + 1),
146+ password: 'validpass1',
147+ password2: 'validpass1',
148+ }),
149+ redirect: 'manual',
150+ });
151+ expect(res.status).toBe(422);
152+ });
153+
154+ test('username at limit is not schema-rejected', async () => {
155+ // A username at exactly the limit passes schema (may fail business logic due to uniqueness/format)
156+ const res = await fetch(`${BASE}/register`, {
157+ method: 'POST',
158+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
159+ body: new URLSearchParams({
160+ username: 'a'.repeat(config.MAX_USERNAME_BYTES),
161+ password: 'validpass1',
162+ password2: 'validpass1',
163+ }),
164+ redirect: 'manual',
165+ });
166+ // 302 (registered) or 200 (form error like invalid chars), but not 422
167+ expect(res.status).not.toBe(422);
168+ });
169+
170+ test('password over limit is rejected at registration', async () => {
171+ const res = await fetch(`${BASE}/register`, {
172+ method: 'POST',
173+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
174+ body: new URLSearchParams({
175+ username: 'newuser',
176+ password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
177+ password2: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
178+ }),
179+ redirect: 'manual',
180+ });
181+ expect(res.status).toBe(422);
182+ });
183+
184+ test('new_password over limit is rejected at settings/password', async () => {
185+ const res = await post('/settings/password', {
186+ current_password: ADMIN_PASS,
187+ new_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
188+ confirm_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
189+ });
190+ expect(res.status).toBe(422);
191+ });
192+});
193+
194+// ─── Tag name validation ──────────────────────────────────────────────────────
195+
196+describe('tag name validation', () => {
197+ const validTags = ['v1.0.0', 'release-2', '1.0+build.1', 'v1_alpha'];
198+ const invalidTags = ['v1.0~1', 'tag with space', 'v1:2', 'v1^2', 'ref/head', 'v1?', 'v1*'];
199+
200+ for (const tag of validTags) {
201+ test(`valid tag "${tag}" is accepted`, async () => {
202+ const res = await post('/val-repo/releases', {
203+ create_tag: 'on',
204+ tag_name: tag,
205+ revision: 'main',
206+ name: `Release ${tag}`,
207+ });
208+ // 302 = success redirect, or 200 = form with error (e.g. tag already exists) — either is fine
209+ // What's NOT acceptable is a 422 schema error
210+ expect(res.status).not.toBe(422);
211+ });
212+ }
213+
214+ for (const tag of invalidTags) {
215+ test(`invalid tag "${tag}" is rejected`, async () => {
216+ const res = await post('/val-repo/releases', {
217+ create_tag: 'on',
218+ tag_name: tag,
219+ revision: 'main',
220+ name: `Release ${tag}`,
221+ });
222+ // Should get a 200 with an inline form error (business-logic validation)
223+ expect(res.status).toBe(200);
224+ const body = await res.text();
225+ expect(body).toContain('may only contain');
226+ });
227+ }
228+});
229+
230+// ─── LIKE wildcard escaping in repo search ────────────────────────────────────
231+
232+describe('repo search LIKE escaping', () => {
233+ beforeAll(async () => {
234+ // Create repos with and without underscore/special chars to verify search behavior
235+ await post('/new', { name: 'search-under_score' });
236+ await post('/new', { name: 'search-nodash' });
237+ });
238+
239+ test('search for "_" returns only repos with literal underscore', async () => {
240+ const res = await fetch(`${BASE}/?q=${encodeURIComponent('_')}`, {
241+ headers: { Cookie: sessionCookie },
242+ });
243+ const body = await res.text();
244+ expect(body).toContain('search-under_score');
245+ expect(body).not.toContain('search-nodash');
246+ expect(body).not.toContain('val-repo');
247+ });
248+
249+ test('search for "%" returns no repos (no repo has literal % in name)', async () => {
250+ const res = await fetch(`${BASE}/?q=${encodeURIComponent('%')}`, {
251+ headers: { Cookie: sessionCookie },
252+ });
253+ const body = await res.text();
254+ expect(body).not.toContain('search-under_score');
255+ expect(body).not.toContain('search-nodash');
256+ expect(body).not.toContain('val-repo');
257+ });
258+
259+ test('normal substring search still works', async () => {
260+ const res = await fetch(`${BASE}/?q=search-under`, {
261+ headers: { Cookie: sessionCookie },
262+ });
263+ const body = await res.text();
264+ expect(body).toContain('search-under_score');
265+ expect(body).not.toContain('search-nodash');
266+ });
267+});