Skip to content

Commit c7fc35d

Browse files
committed
small fixes
1 parent eabb290 commit c7fc35d

File tree

6 files changed

+172
-4
lines changed

6 files changed

+172
-4
lines changed

TODOS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# TODOS
2+
3+
Generated: 2025-12-24
4+
5+
## Batch 1: Critical Security Fixes
6+
7+
- [ ] Fix SQL injection in message search (`apps/server/src/domains/messages/service.ts` - escape `ilike` pattern)
8+
- [ ] Add file path sanitization (`apps/server/src/domains/files/service.ts` - prevent `../` traversal)
9+
- [ ] Add WebSocket auth re-validation on subscribe (`apps/server/src/ws/index.ts` - check permissions on subscribe)
10+
- [ ] Add rate limiting middleware (`apps/server/src/middleware/` - create rate-limit.ts)
11+
12+
## Batch 2: High-Impact Performance Fixes
13+
14+
- [ ] Fix N+1 query in message list (`apps/server/src/domains/messages/service.ts` - include reactions/attachments in query)
15+
- [ ] Remove duplicate useQuery per message (`apps/desktop/src/components/MessageItem.svelte` - pass reactions as props)
16+
- [ ] Deduplicate permission checks (`apps/server/src/domains/permissions/service.ts` - cache within request context)
17+
- [ ] Lazy load CodeMirror and emoji-picker (`apps/desktop/src/components/` - use dynamic imports)
18+
19+
## Batch 3: Code Quality & Dead Code Removal
20+
21+
- [ ] Delete unused example/ directory (`.storybook`, `apps/desktop/src/example/`)
22+
- [ ] Remove console.log/error statements (9 files - use proper logger or delete)
23+
- [ ] Split large components: `MessageItem.svelte` (143 lines → ~50 lines each)
24+
- [ ] Split large components: `ChannelList.svelte` (141 lines → ~50 lines each)
25+
26+
## Batch 4: Accessibility Fixes
27+
28+
- [ ] Add aria-labels to icon buttons (`apps/desktop/src/components/` - all IconButton components)
29+
- [ ] Replace alert() with toast notifications (`apps/desktop/src/` - create toast utility)
30+
- [ ] Add form labels for WCAG compliance (`apps/desktop/src/routes/` - all form inputs)
31+
- [ ] Standardize placeholder text language (`apps/desktop/src/` - choose Japanese or English consistently)
32+
33+
## Deferred (Needs User Confirmation)
34+
35+
- [ ] DISABLE_AUTH flag - clarify production usage policy (`apps/server/src/middleware/auth.ts`)
36+
- [ ] Add CSRF protection - confirm if needed for API-only backend (`apps/server/src/middleware/`)
37+
- [ ] Add security headers - confirm headers policy (`apps/server/src/index.ts`)
38+
- [ ] shadow-2xl usage - confirm if Clarity design principles apply (`apps/desktop/src/components/`)
39+
- [ ] Empty state CTAs - requires design decisions (multiple files)
40+
41+
## Rejected (Low Value / Out of Scope)
42+
43+
- Duplicate logic in unread.ts - minimal impact, refactor during feature work
44+
- Test coverage improvements - separate test-focused sprint needed
45+
- Flaky waitForTimeout(500) - address when writing new E2E tests
46+
- Page Object Model - requires significant test refactoring
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { sanitizeFilename } from "./validation.ts";
3+
4+
describe("sanitizeFilename security", () => {
5+
test("blocks path traversal attempts", () => {
6+
expect(() => sanitizeFilename("..")).toThrow(
7+
"Filename cannot contain '..'",
8+
);
9+
expect(() => sanitizeFilename("../etc/passwd")).toThrow(
10+
"Filename cannot contain '..'",
11+
);
12+
expect(() => sanitizeFilename("foo/../bar")).toThrow(
13+
"Filename cannot contain '..'",
14+
);
15+
});
16+
17+
test("blocks hidden files", () => {
18+
expect(() => sanitizeFilename(".env")).toThrow(
19+
"Filename cannot start with '.'",
20+
);
21+
expect(() => sanitizeFilename(".git")).toThrow(
22+
"Filename cannot start with '.'",
23+
);
24+
});
25+
26+
test("blocks empty filenames", () => {
27+
expect(() => sanitizeFilename("")).toThrow("Filename cannot be empty");
28+
expect(() => sanitizeFilename(" ")).toThrow("Filename cannot be empty");
29+
});
30+
31+
test("allows valid filenames", () => {
32+
expect(() => sanitizeFilename("document.pdf")).not.toThrow();
33+
expect(() => sanitizeFilename("my-file_123.txt")).not.toThrow();
34+
expect(() => sanitizeFilename("日本語ファイル.txt")).not.toThrow();
35+
});
36+
37+
test("sanitizes special characters", () => {
38+
expect(sanitizeFilename("file/name.txt")).toBe("file_name.txt");
39+
expect(sanitizeFilename("file\\name.txt")).toBe("file_name.txt");
40+
});
41+
42+
test("limits filename length", () => {
43+
const longName = `${"a".repeat(300)}.txt`;
44+
expect(sanitizeFilename(longName).length).toBe(255);
45+
});
46+
});

apps/server/src/domains/files/validation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,25 @@ export function validateFile(size: number, mimeType: string): string | null {
3939

4040
/**
4141
* Sanitizes filename to prevent security issues.
42+
* Blocks path traversal attempts and hidden files.
4243
* Removes special characters and limits length.
4344
*/
4445
export function sanitizeFilename(filename: string): string {
46+
// Block path traversal attempts
47+
if (filename.includes("..")) {
48+
throw new Error("Filename cannot contain '..'");
49+
}
50+
51+
// Block hidden files
52+
if (filename.startsWith(".")) {
53+
throw new Error("Filename cannot start with '.'");
54+
}
55+
56+
// Block empty or whitespace-only filenames
57+
if (!filename.trim()) {
58+
throw new Error("Filename cannot be empty");
59+
}
60+
4561
return filename
4662
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF._-]/g, "_")
4763
.substring(0, 255);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { escapeLikePattern } from "./search.routes.ts";
3+
4+
describe("escapeLikePattern", () => {
5+
test("escapes % wildcard", () => {
6+
expect(escapeLikePattern("100%")).toBe("100\\%");
7+
});
8+
9+
test("escapes _ wildcard", () => {
10+
expect(escapeLikePattern("user_name")).toBe("user\\_name");
11+
});
12+
13+
test("escapes backslash", () => {
14+
expect(escapeLikePattern("path\\to\\file")).toBe("path\\\\to\\\\file");
15+
});
16+
17+
test("escapes multiple special chars", () => {
18+
expect(escapeLikePattern("50%_discount\\sale")).toBe(
19+
"50\\%\\_discount\\\\sale",
20+
);
21+
});
22+
23+
test("normal text unchanged", () => {
24+
expect(escapeLikePattern("hello world")).toBe("hello world");
25+
});
26+
});

apps/server/src/domains/messages/search.routes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import { channels, messages, users } from "../../db/schema.ts";
55
import { authMiddleware } from "../../middleware/auth.ts";
66
import { requireOrganizationMembership } from "../organizations/permissions.ts";
77

8+
/**
9+
* Escapes special characters in LIKE pattern (%, _, \).
10+
* Prevents SQL injection by treating user input as literal text.
11+
*/
12+
export function escapeLikePattern(pattern: string): string {
13+
return pattern
14+
.replace(/\\/g, "\\\\")
15+
.replace(/%/g, "\\%")
16+
.replace(/_/g, "\\_");
17+
}
18+
819
/**
920
* Handles message search operations.
1021
* Provides endpoint to search messages within a channel.
@@ -49,7 +60,7 @@ export const messageSearchRoutes = new Elysia().use(authMiddleware).get(
4960
.where(
5061
and(
5162
eq(messages.channelId, query.channelId),
52-
ilike(messages.content, `%${query.q}%`),
63+
ilike(messages.content, `%${escapeLikePattern(query.q)}%`),
5364
),
5465
)
5566
.orderBy(asc(messages.createdAt))

apps/server/src/ws/manager.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { eq } from "drizzle-orm";
2+
import { db } from "../db/index.ts";
3+
import { channels } from "../db/schema.ts";
4+
import { requireOrganizationMembership } from "../domains/organizations/permissions.ts";
15
import type {
26
WsBroadcastEvent,
37
WsConnection,
@@ -39,12 +43,31 @@ export class WsManager {
3943

4044
/**
4145
* Subscribes a connection to a channel.
46+
* Verifies user has permission (is member of the organization owning the channel).
47+
* @returns true if subscribed successfully, false if permission denied
4248
*/
43-
subscribe(connectionId: string, channelId: string) {
49+
async subscribe(connectionId: string, channelId: string): Promise<boolean> {
4450
const conn = this.connections.get(connectionId);
45-
if (conn) {
46-
conn.channels.add(channelId);
51+
if (!conn) return false;
52+
53+
// Fetch channel to get organization ID
54+
const [channel] = await db
55+
.select()
56+
.from(channels)
57+
.where(eq(channels.id, channelId))
58+
.limit(1);
59+
60+
if (!channel) return false;
61+
62+
// Verify user is member of the organization
63+
try {
64+
await requireOrganizationMembership(conn.user.id, channel.organizationId);
65+
} catch {
66+
return false;
4767
}
68+
69+
conn.channels.add(channelId);
70+
return true;
4871
}
4972

5073
/**

0 commit comments

Comments
 (0)