Skip to content

Commit 583f61d

Browse files
feat: resolve issues #129, #127, #126, #38 — XSS prevention, POSIX shell, public board sharing (#130)
## Summary Bulk resolution of 4 open issues: - **#129** XSS prevention smoke tests (comment text, board title, javascript: URL) - **#127** Security hardening (CSP headers, explicit column selection, defense-in-depth) - **#126** POSIX shell compliance in package.json scripts - **#38** Public board sharing (Phase 1: read-only public view via share slug) ## Changes - Added XSS smoke E2E tests for stored XSS vectors - Added CSP Report-Only headers in next.config.ts - Fixed `source .env` → `. .env` for POSIX compliance - Added public board page at /public/[slug] with SSR - Added board sharing toggle in BoardSettingsDialog - Added RLS policies for public board access - Added rate limiting for public board views - Extracted shared `toRepoCardDomain` mapper - Added `React.cache()` deduplication for public board fetch Closes #129 Closes #127 Closes #126 Closes #38
1 parent eb5aa79 commit 583f61d

File tree

62 files changed

+1040
-119
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1040
-119
lines changed

.github/workflows/e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
# Start local Supabase (Docker is pre-installed on ubuntu-latest)
5252
- name: Start Supabase
5353
run: |
54-
cd src/supabase
54+
cd supabase
5555
supabase start
5656
# Apply seed data
5757
supabase db reset --local
@@ -79,7 +79,7 @@ jobs:
7979
- name: Stop Supabase
8080
if: always()
8181
run: |
82-
cd src/supabase
82+
cd supabase
8383
supabase stop || true
8484
8585
- name: Upload blob report

.github/workflows/supabase-production.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ on:
44
push:
55
branches: [main]
66
paths:
7-
- 'src/supabase/migrations/**'
8-
- 'src/supabase/config.toml'
7+
- 'supabase/migrations/**'
8+
- 'supabase/config.toml'
99
- '.github/workflows/supabase-production.yml'
1010
workflow_dispatch:
1111

@@ -28,23 +28,23 @@ jobs:
2828
version: latest
2929

3030
- name: Link to Production Project
31-
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir src
31+
run: supabase link --project-ref "$SUPABASE_PROJECT_REF"
3232
env:
3333
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
3434

3535
- name: Show Pending Migrations
36-
run: supabase migration list --workdir src
36+
run: supabase migration list
3737
env:
3838
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
3939

4040
# Note: Database backups are handled by Supabase Pro Plan's
4141
# automatic daily backups and Point-in-Time Recovery feature.
4242
- name: Apply Migrations
43-
run: supabase db push --linked --workdir src
43+
run: supabase db push --linked
4444
env:
4545
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
4646

4747
- name: Verify Migration Status
48-
run: supabase migration list --workdir src
48+
run: supabase migration list
4949
env:
5050
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ Thumbs.db
5959

6060
# Supabase
6161
.supabase/
62-
src/supabase/.branches/
63-
src/supabase/.temp/
62+
supabase/.branches/
63+
supabase/.temp/
64+
supabase/.env
6465

6566
# Storybook
6667
storybook-static/
@@ -88,6 +89,5 @@ blob-report/
8889
# Logs
8990
logs/
9091

91-
supabase/
9292
.1code/
9393
claudedocs/

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ await supabase.from('Board').select('*') // ❌ Wrong
8585

8686
**Prerequisites:** Docker Desktop running
8787

88-
**🔴 CRITICAL:** Always use the npm scripts to start/stop Supabase. Running bare `supabase start` from the project root will NOT load GitHub OAuth credentials (the `env()` substitution in `config.toml` requires shell env vars from `src/supabase/.env`).
88+
**🔴 CRITICAL:** Always use the npm scripts to start/stop Supabase. Running bare `supabase start` from the project root will NOT load GitHub OAuth credentials (the `env()` substitution in `config.toml` requires shell env vars from `supabase/.env`).
8989

9090
```bash
9191
# Start local Supabase (sources .env + applies migrations)
@@ -130,9 +130,9 @@ supabase db reset
130130

131131
### Local Supabase + GitHub OAuth Setup
132132

133-
**🔴 CRITICAL:** Supabase CLI reads environment variables from `src/supabase/.env`, NOT from root `.env`.
133+
**🔴 CRITICAL:** Supabase CLI reads environment variables from `supabase/.env`, NOT from root `.env`.
134134

135-
1. **config.toml** (`src/supabase/config.toml`) must have:
135+
1. **config.toml** (`supabase/config.toml`) must have:
136136

137137
```toml
138138
[auth.external.github]
@@ -142,7 +142,7 @@ supabase db reset
142142
redirect_uri = "http://127.0.0.1:54321/auth/v1/callback"
143143
```
144144

145-
2. **Create `src/supabase/.env`** with GitHub OAuth credentials:
145+
2. **Create `supabase/.env`** with GitHub OAuth credentials:
146146

147147
```bash
148148
GITHUB_CLIENT_ID="your_client_id"
@@ -154,7 +154,7 @@ supabase db reset
154154
supabase stop && supabase start
155155
```
156156

157-
**⚠️ Note:** `src/supabase/.env` is gitignored. Each developer must create their own from the GitHub OAuth App settings.
157+
**⚠️ Note:** `supabase/.env` is gitignored. Each developer must create their own from the GitHub OAuth App settings.
158158

159159
### Production Migration Procedure
160160

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ pnpm db:reset
109109

110110
### GitHub OAuth for Local Development
111111

112-
Supabase CLI reads OAuth credentials from `src/supabase/.env` (not root `.env`).
112+
Supabase CLI reads OAuth credentials from `supabase/.env` (not root `.env`).
113113

114-
1. Create `src/supabase/.env`:
114+
1. Create `supabase/.env`:
115115

116116
```bash
117117
GITHUB_CLIENT_ID="your-github-oauth-client-id"

SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -988,10 +988,10 @@ Each Supabase project requires its own GitHub OAuth App:
988988

989989
#### Local Supabase + GitHub OAuth Setup
990990

991-
Supabase CLI reads environment variables from `src/supabase/.env` (NOT root `.env`):
991+
Supabase CLI reads environment variables from `supabase/.env` (NOT root `.env`):
992992

993993
```bash
994-
# src/supabase/.env
994+
# supabase/.env
995995
GITHUB_CLIENT_ID=your_client_id
996996
GITHUB_CLIENT_SECRET=your_client_secret
997997
```
@@ -1011,7 +1011,7 @@ additional_redirect_urls = [
10111011
#### Migration Workflow
10121012

10131013
1. Create migration: `supabase migration new <description>`
1014-
2. Write SQL in `src/supabase/migrations/YYYYMMDDHHMMSS_<description>.sql`
1014+
2. Write SQL in `supabase/migrations/YYYYMMDDHHMMSS_<description>.sql`
10151015
3. Test on dev: `supabase link --project-ref jqtxjzdxczqwsrvevmyk``supabase db push --linked`
10161016
4. Merge to `main` → Production deploys via GitHub Actions
10171017

e2e/logged-in/xss-smoke.spec.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* XSS Smoke Tests
3+
*
4+
* Verifies that user-input rendering paths properly escape XSS payloads.
5+
* Tests the three primary attack surfaces:
6+
* - Comment text (stored XSS)
7+
* - Board title (stored XSS)
8+
* - Project info link URL (javascript: scheme)
9+
*
10+
* @see https://github.com/laststance/gitbox/issues/127
11+
*/
12+
13+
import { test, expect } from '../fixtures/coverage'
14+
import {
15+
BOARD_IDS,
16+
CARD_IDS,
17+
resetProjectInfoComments,
18+
} from '../helpers/db-query'
19+
20+
test.describe('XSS Prevention Smoke Tests (Authenticated)', () => {
21+
test.use({ storageState: 'e2e/.auth/user.json' })
22+
23+
const BOARD_URL = `/board/${BOARD_IDS.testBoard}`
24+
const XSS_SCRIPT_PAYLOAD = '<script>alert("xss")</script>'
25+
const XSS_IMG_PAYLOAD = '<img src=x onerror=alert("xss")>'
26+
const XSS_JS_URL = 'javascript:alert(1)'
27+
28+
test.beforeEach(async ({ page }) => {
29+
await resetProjectInfoComments()
30+
await page.goto(BOARD_URL)
31+
await page.waitForLoadState('networkidle')
32+
await expect(
33+
page.locator('[data-testid^="repo-card-"]').first(),
34+
).toBeVisible({ timeout: 10000 })
35+
})
36+
37+
test('should escape script tags in comment text', async ({ page }) => {
38+
// Track any dialog/alert events — should never fire
39+
let alertFired = false
40+
page.on('dialog', async (dialog) => {
41+
alertFired = true
42+
await dialog.dismiss()
43+
})
44+
45+
// Click on card-1 comment to enter edit mode
46+
const commentDisplay = page.locator(
47+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-display"]`,
48+
)
49+
await expect(commentDisplay).toBeVisible({ timeout: 10000 })
50+
await commentDisplay.click()
51+
52+
// Type XSS payload into textarea
53+
const textarea = page.locator(
54+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-inline-edit"] textarea`,
55+
)
56+
await expect(textarea).toBeVisible({ timeout: 5000 })
57+
await textarea.fill(XSS_SCRIPT_PAYLOAD)
58+
await textarea.press('Enter')
59+
60+
// Verify the XSS payload is rendered as escaped text, not executed
61+
// (toContainText auto-waits for the save to complete)
62+
const commentText = page.locator(
63+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-text"]`,
64+
)
65+
await expect(commentText).toContainText('<script>')
66+
67+
// Verify no alert fired after DOM settled
68+
expect(alertFired).toBe(false)
69+
})
70+
71+
test('should escape img onerror payload in comment text', async ({
72+
page,
73+
}) => {
74+
let alertFired = false
75+
page.on('dialog', async (dialog) => {
76+
alertFired = true
77+
await dialog.dismiss()
78+
})
79+
80+
const commentDisplay = page.locator(
81+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-display"]`,
82+
)
83+
await expect(commentDisplay).toBeVisible({ timeout: 10000 })
84+
await commentDisplay.click()
85+
86+
const textarea = page.locator(
87+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-inline-edit"] textarea`,
88+
)
89+
await expect(textarea).toBeVisible({ timeout: 5000 })
90+
await textarea.fill(XSS_IMG_PAYLOAD)
91+
await textarea.press('Enter')
92+
93+
// Verify the XSS payload is rendered as escaped text, not executed
94+
// (toContainText auto-waits for the save to complete)
95+
const commentText = page.locator(
96+
`[data-testid="repo-card-${CARD_IDS.card1}"] [data-testid="comment-text"]`,
97+
)
98+
await expect(commentText).toContainText('<img')
99+
100+
// Verify no alert fired after DOM settled
101+
expect(alertFired).toBe(false)
102+
})
103+
104+
test('should reject javascript: URL in project info link', async ({
105+
page,
106+
}) => {
107+
let alertFired = false
108+
page.on('dialog', async (dialog) => {
109+
alertFired = true
110+
await dialog.dismiss()
111+
})
112+
113+
// Open the first card's note/project info modal
114+
const card = page.locator(`[data-testid="repo-card-${CARD_IDS.card1}"]`)
115+
await card.click()
116+
117+
// Wait for note modal to appear
118+
const modal = page.locator('[role="dialog"]')
119+
await expect(modal).toBeVisible({ timeout: 5000 })
120+
121+
// Find a link edit button and click it
122+
let enteredEditMode = false
123+
const editButton = modal.locator('[data-testid^="url-edit-"]').first()
124+
if (await editButton.isVisible({ timeout: 3000 }).catch(() => false)) {
125+
await editButton.click()
126+
enteredEditMode = true
127+
} else {
128+
// If no existing link, look for "add link" button
129+
const addLink = modal.locator('button:has-text("Add")').first()
130+
if (await addLink.isVisible({ timeout: 3000 }).catch(() => false)) {
131+
await addLink.click()
132+
enteredEditMode = true
133+
}
134+
}
135+
136+
// Ensure we actually found an edit/add button — test is meaningless otherwise
137+
expect(enteredEditMode).toBe(true)
138+
139+
// Try to enter javascript: URL
140+
const urlInput = modal.locator('[data-testid^="url-input-"]').first()
141+
await expect(urlInput).toBeVisible({ timeout: 3000 })
142+
await urlInput.fill(XSS_JS_URL)
143+
await urlInput.press('Tab')
144+
145+
// Wait for validation
146+
await page.waitForTimeout(500)
147+
148+
// Should show error — javascript: is not allowed
149+
const error = modal.locator('[role="alert"]').first()
150+
await expect(error).toBeVisible({ timeout: 3000 })
151+
await expect(error).toContainText('http')
152+
153+
expect(alertFired).toBe(false)
154+
})
155+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Public Board E2E Tests
3+
*
4+
* Tests for the public board sharing feature (unauthenticated).
5+
* Validates 404 handling for invalid/non-existent slugs.
6+
*/
7+
8+
import { test, expect } from '../fixtures/coverage'
9+
10+
test.describe('Public Board', () => {
11+
test('should return 404 for invalid slug format', async ({ page }) => {
12+
// Slug must be exactly 12 hex chars — "invalid" doesn't match
13+
const response = await page.goto('/public/invalid-slug')
14+
15+
expect(response?.status()).toBe(404)
16+
})
17+
18+
test('should return 404 for non-existent valid slug', async ({ page }) => {
19+
// Valid format (12 hex chars) but no matching board
20+
const response = await page.goto('/public/aabbccddeeff')
21+
22+
expect(response?.status()).toBe(404)
23+
})
24+
25+
test('should return 404 for empty slug', async ({ page }) => {
26+
const response = await page.goto('/public/')
27+
28+
// Next.js may return 404 for this route
29+
expect(response?.status()).toBe(404)
30+
})
31+
})

eslint.config.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default defineConfig([
6262
'./e2e/tablet-landscape/**',
6363
'.storybook/**',
6464
'**/.husky/**',
65+
'_trials/**',
6566
'src/lib/supabase/types.ts',
6667
'src/lib/supabase/database.types.ts',
6768
'src/lib/github/api.ts',
@@ -94,6 +95,20 @@ export default defineConfig([
9495
'Use axios instead of fetch for MSW compatibility. Import from lib/axios.ts.',
9596
},
9697
],
98+
// Ban revalidatePath/revalidateTag - Supabase SDK doesn't use Next.js cache
99+
'no-restricted-imports': [
100+
'error',
101+
{
102+
paths: [
103+
{
104+
name: 'next/cache',
105+
importNames: ['revalidatePath', 'revalidateTag'],
106+
message:
107+
'Supabase SDK does not use Next.js cache. Use Redux optimistic updates instead.',
108+
},
109+
],
110+
},
111+
],
97112
// Ban console usage - use logger (server) or Sentry (client) instead
98113
'no-console': 'error',
99114
},

mocks/handlers/data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ const INITIAL_MOCK_BOARDS = [
114114
theme: 'sunrise',
115115
settings: null,
116116
is_favorite: false,
117+
is_public: false,
118+
share_slug: null as string | null,
117119
position: 1,
118120
created_at: '2024-01-01T00:00:00.000Z',
119121
updated_at: '2024-01-01T00:00:00.000Z',
@@ -126,6 +128,8 @@ const INITIAL_MOCK_BOARDS = [
126128
theme: 'midnight',
127129
settings: null,
128130
is_favorite: false,
131+
is_public: false,
132+
share_slug: null as string | null,
129133
position: 0,
130134
created_at: '2024-01-02T00:00:00.000Z',
131135
updated_at: '2024-01-02T00:00:00.000Z',

0 commit comments

Comments
 (0)