Skip to content

Commit 070804b

Browse files
committed
security: harden API endpoints against injection, open redirect, and abuse
- Fix shell injection in install scripts via base64 encoding of custom_script and sanitization of username/slug before shell interpolation - Fix open redirect in OAuth login and callback (validate return_to at both entry and redirect points) - Add input validation for custom_script (max 10k, no null bytes) and dotfiles_repo (HTTPS-only, allowlisted hosts) - Add in-memory sliding window rate limiting across all API endpoints - Add security headers (CSP, HSTS, X-Frame-Options, nosniff) to all responses including early-return script/redirect responses - Use crypto.getRandomValues() for CLI device code generation - Add payload size validation (100KB max) for snapshot uploads - Genericize error messages to prevent information leakage
1 parent 9e0b2f8 commit 070804b

File tree

13 files changed

+371
-26
lines changed

13 files changed

+371
-26
lines changed

src/hooks.server.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,43 @@ import { generateInstallScript } from '$lib/server/install-script';
33

44
const INSTALL_SCRIPT_URL = 'https://raw.githubusercontent.com/openbootdotdev/openboot/main/scripts/install.sh';
55

6+
const SECURITY_HEADERS: Record<string, string> = {
7+
'X-Frame-Options': 'DENY',
8+
'X-Content-Type-Options': 'nosniff',
9+
'Referrer-Policy': 'strict-origin-when-cross-origin',
10+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
11+
'X-XSS-Protection': '0',
12+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
13+
'Content-Security-Policy': [
14+
"default-src 'self'",
15+
"script-src 'self' 'unsafe-inline'",
16+
"style-src 'self' 'unsafe-inline'",
17+
"img-src 'self' https: data:",
18+
"font-src 'self' https://fonts.gstatic.com",
19+
"connect-src 'self' https://api.github.com https://accounts.google.com https://oauth2.googleapis.com https://formulae.brew.sh https://registry.npmjs.org",
20+
"frame-ancestors 'none'",
21+
"base-uri 'self'",
22+
"form-action 'self' https://github.com https://accounts.google.com"
23+
].join('; ')
24+
};
25+
26+
function withSecurityHeaders(response: Response): Response {
27+
const headers = new Headers(response.headers);
28+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
29+
headers.set(key, value);
30+
}
31+
return new Response(response.body, {
32+
status: response.status,
33+
statusText: response.statusText,
34+
headers
35+
});
36+
}
37+
638
export const handle: Handle = async ({ event, resolve }) => {
739
const path = event.url.pathname;
840

941
if (path === '/install.sh') {
10-
return Response.redirect(INSTALL_SCRIPT_URL, 302);
42+
return withSecurityHeaders(Response.redirect(INSTALL_SCRIPT_URL, 302));
1143
}
1244

1345
const shortAliasMatch = path.match(/^\/([a-z0-9-]+)$/);
@@ -31,14 +63,14 @@ export const handle: Handle = async ({ event, resolve }) => {
3163

3264
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE alias = ?').bind(alias).run().catch(() => {});
3365

34-
return new Response(script, {
66+
return withSecurityHeaders(new Response(script, {
3567
headers: {
3668
'Content-Type': 'text/plain; charset=utf-8',
3769
'Cache-Control': 'no-cache'
3870
}
39-
});
71+
}));
4072
} else if (isBrowser) {
41-
return Response.redirect(`/${config.username}/${config.slug}`, 302);
73+
return withSecurityHeaders(Response.redirect(`/${config.username}/${config.slug}`, 302));
4274
}
4375
}
4476
}
@@ -65,17 +97,18 @@ export const handle: Handle = async ({ event, resolve }) => {
6597

6698
env.DB.prepare('UPDATE configs SET install_count = install_count + 1 WHERE user_id = ? AND slug = ?').bind(user.id, slug).run().catch(() => {});
6799

68-
return new Response(script, {
100+
return withSecurityHeaders(new Response(script, {
69101
headers: {
70102
'Content-Type': 'text/plain; charset=utf-8',
71103
'Cache-Control': 'no-cache'
72104
}
73-
});
105+
}));
74106
}
75107
}
76108
}
77109
}
78110
}
79111

80-
return resolve(event);
112+
const response = await resolve(event);
113+
return withSecurityHeaders(response);
81114
};

src/lib/server/install-script.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
function sanitizeShellArg(value: string): string {
2+
return value.replace(/[^a-zA-Z0-9_\-]/g, '');
3+
}
4+
15
export function generateInstallScript(
26
username: string,
37
slug: string,
48
customScript: string,
59
dotfilesRepo: string
610
): string {
11+
const safeUsername = sanitizeShellArg(username);
12+
const safeSlug = sanitizeShellArg(slug);
13+
714
return `#!/bin/bash
815
set -e
916
1017
echo "========================================"
1118
echo " OpenBoot - Custom Install"
12-
echo " Config: @${username}/${slug}"
19+
echo " Config: @${safeUsername}/${safeSlug}"
1320
echo "========================================"
1421
echo ""
1522
@@ -66,16 +73,17 @@ echo "Downloading OpenBoot..."
6673
curl -fsSL "\$OPENBOOT_URL" -o "\$OPENBOOT_BIN"
6774
chmod +x "\$OPENBOOT_BIN"
6875
69-
echo "Using remote config: @${username}/${slug}"
70-
"\$OPENBOOT_BIN" --user ${username}/${slug} "\$@"
76+
echo "Using remote config: @${safeUsername}/${safeSlug}"
77+
"\$OPENBOOT_BIN" --user "${safeUsername}/${safeSlug}" "\$@"
7178
7279
${
7380
customScript
7481
? `
7582
echo ""
7683
echo "=== Running Custom Post-Install Script ==="
7784
set +e
78-
${customScript}
85+
CUSTOM_SCRIPT_B64="${btoa(unescape(encodeURIComponent(customScript)))}"
86+
echo "\$CUSTOM_SCRIPT_B64" | base64 -d | bash
7987
CUSTOM_SCRIPT_EXIT=$?
8088
set -e
8189
if [ $CUSTOM_SCRIPT_EXIT -ne 0 ]; then
@@ -94,6 +102,11 @@ echo "=== Setting up Dotfiles ==="
94102
DOTFILES_REPO="${dotfilesRepo}"
95103
DOTFILES_DIR="\$HOME/.dotfiles"
96104
105+
if [[ ! "\$DOTFILES_REPO" =~ ^https:// ]]; then
106+
echo "Error: Invalid dotfiles repo URL (must use HTTPS)"
107+
exit 1
108+
fi
109+
97110
if [ -d "\$DOTFILES_DIR" ]; then
98111
echo "Dotfiles directory already exists at \$DOTFILES_DIR"
99112
echo "Pulling latest changes..."

src/lib/server/rate-limit.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* In-memory sliding window rate limiter (per Worker isolate).
3+
*
4+
* PRODUCTION NOTE: This is NOT globally consistent across Cloudflare Workers isolates.
5+
* For production-grade global rate limiting, configure Cloudflare Rate Limiting rules:
6+
* https://developers.cloudflare.com/waf/rate-limiting-rules/
7+
*
8+
* Recommended Cloudflare rule:
9+
* - Rate: 100 requests per minute per IP
10+
* - Action: Block with 429 status
11+
* - Scope: /api/*
12+
*/
13+
14+
interface RateLimitConfig {
15+
maxRequests: number;
16+
windowMs: number;
17+
}
18+
19+
interface RateLimitResult {
20+
allowed: boolean;
21+
retryAfter?: number;
22+
}
23+
24+
class RateLimiter {
25+
private requests: Map<string, number[]> = new Map();
26+
private lastCleanup: number = Date.now();
27+
private readonly CLEANUP_INTERVAL_MS = 60000;
28+
29+
check(key: string, config: RateLimitConfig): RateLimitResult {
30+
const now = Date.now();
31+
const windowStart = now - config.windowMs;
32+
33+
if (now - this.lastCleanup > this.CLEANUP_INTERVAL_MS) {
34+
this.cleanup(now);
35+
}
36+
37+
let timestamps = this.requests.get(key) || [];
38+
timestamps = timestamps.filter((ts) => ts > windowStart);
39+
40+
if (timestamps.length >= config.maxRequests) {
41+
const oldestInWindow = timestamps[0];
42+
const retryAfter = oldestInWindow + config.windowMs - now;
43+
return { allowed: false, retryAfter: Math.max(retryAfter, 0) };
44+
}
45+
46+
timestamps.push(now);
47+
this.requests.set(key, timestamps);
48+
return { allowed: true };
49+
}
50+
51+
private cleanup(now: number): void {
52+
const cutoff = now - 600000;
53+
for (const [key, timestamps] of this.requests.entries()) {
54+
const recent = timestamps.filter((ts) => ts > cutoff);
55+
if (recent.length === 0) {
56+
this.requests.delete(key);
57+
} else {
58+
this.requests.set(key, recent);
59+
}
60+
}
61+
this.lastCleanup = now;
62+
}
63+
}
64+
65+
const rateLimiter = new RateLimiter();
66+
67+
export function getRateLimitKey(endpoint: string, identifier: string): string {
68+
return `${endpoint}:${identifier}`;
69+
}
70+
71+
export const RATE_LIMITS = {
72+
AUTH_LOGIN: { maxRequests: 10, windowMs: 60000 },
73+
AUTH_CALLBACK: { maxRequests: 10, windowMs: 60000 },
74+
CLI_START: { maxRequests: 5, windowMs: 60000 },
75+
CLI_APPROVE: { maxRequests: 10, windowMs: 60000 },
76+
CLI_POLL: { maxRequests: 20, windowMs: 60000 },
77+
CONFIG_READ: { maxRequests: 30, windowMs: 60000 },
78+
CONFIG_WRITE: { maxRequests: 30, windowMs: 60000 }
79+
} as const;
80+
81+
export function checkRateLimit(key: string, config: RateLimitConfig): RateLimitResult {
82+
return rateLimiter.check(key, config);
83+
}

src/lib/server/validation.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
interface ValidationResult {
2+
valid: boolean;
3+
error?: string;
4+
}
5+
6+
/** Validates custom script: max 10k chars, no null bytes. Base64-encoded at generation time to prevent shell injection. */
7+
export function validateCustomScript(script: string | null | undefined): ValidationResult {
8+
if (!script) {
9+
return { valid: true };
10+
}
11+
12+
if (typeof script !== 'string') {
13+
return { valid: false, error: 'Custom script must be a string' };
14+
}
15+
16+
if (script.length > 10000) {
17+
return { valid: false, error: 'Custom script must be less than 10,000 characters' };
18+
}
19+
20+
if (script.includes('\x00')) {
21+
return { valid: false, error: 'Custom script contains invalid characters' };
22+
}
23+
24+
return { valid: true };
25+
}
26+
27+
/** Validates dotfiles repo URL: HTTPS only, allowed hosts (github/gitlab/bitbucket/codeberg), valid path, max 500 chars. */
28+
export function validateDotfilesRepo(url: string | null | undefined): ValidationResult {
29+
if (!url || url === '') {
30+
return { valid: true };
31+
}
32+
33+
if (typeof url !== 'string') {
34+
return { valid: false, error: 'Dotfiles repo must be a string' };
35+
}
36+
37+
if (url.length > 500) {
38+
return { valid: false, error: 'Dotfiles repo URL is too long' };
39+
}
40+
41+
if (!url.startsWith('https://')) {
42+
return { valid: false, error: 'Dotfiles repo must use HTTPS' };
43+
}
44+
45+
try {
46+
const parsed = new URL(url);
47+
const hostname = parsed.hostname.toLowerCase();
48+
49+
const allowedHosts = ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org'];
50+
const isAllowed = allowedHosts.includes(hostname);
51+
52+
if (!isAllowed) {
53+
return {
54+
valid: false,
55+
error: 'Dotfiles repo must be hosted on GitHub, GitLab, Bitbucket, or Codeberg'
56+
};
57+
}
58+
59+
if (parsed.pathname.includes('..') || parsed.pathname.includes('//')) {
60+
return { valid: false, error: 'Invalid dotfiles repo URL path' };
61+
}
62+
63+
if (!/^\/[\w\-\.\/]+(?:\.git)?$/.test(parsed.pathname)) {
64+
return { valid: false, error: 'Invalid dotfiles repo URL format' };
65+
}
66+
67+
return { valid: true };
68+
} catch {
69+
return { valid: false, error: 'Invalid dotfiles repo URL' };
70+
}
71+
}
72+
73+
/** Validates return_to path — prevents open redirect (relative paths only, safe chars).
74+
* Allows query strings (needed for cli-auth?code=XXX flow). */
75+
export function validateReturnTo(path: string | null | undefined): boolean {
76+
if (!path || typeof path !== 'string') {
77+
return false;
78+
}
79+
80+
if (!path.startsWith('/')) {
81+
return false;
82+
}
83+
84+
if (path.startsWith('//')) {
85+
return false;
86+
}
87+
88+
// Allow path + optional query string with safe characters
89+
return /^\/[a-zA-Z0-9\-_/]*(\?[a-zA-Z0-9\-_=&%]*)?$/.test(path);
90+
}

src/routes/api/auth/callback/github/+server.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import { redirect } from '@sveltejs/kit';
1+
import { redirect, json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
33
import { signToken, generateId } from '$lib/server/auth';
4+
import { checkRateLimit, getRateLimitKey, RATE_LIMITS } from '$lib/server/rate-limit';
5+
import { validateReturnTo } from '$lib/server/validation';
46

57
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
68
const GITHUB_USER_URL = 'https://api.github.com/user';
79

8-
export const GET: RequestHandler = async ({ url, platform, cookies }) => {
10+
export const GET: RequestHandler = async ({ url, platform, cookies, request }) => {
911
const env = platform?.env;
1012
if (!env) throw new Error('Platform env not available');
1113

14+
const clientIp = request.headers.get('cf-connecting-ip') || 'unknown';
15+
const rl = checkRateLimit(getRateLimitKey('auth-callback-github', clientIp), RATE_LIMITS.AUTH_CALLBACK);
16+
if (!rl.allowed) {
17+
return json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfter! / 1000)) } });
18+
}
19+
1220
const code = url.searchParams.get('code');
1321
const state = url.searchParams.get('state');
1422
const savedState = cookies.get('auth_state');
@@ -101,7 +109,8 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => {
101109
maxAge: thirtyDays
102110
});
103111

104-
const returnTo = cookies.get('auth_return_to') || '/dashboard';
112+
const returnToRaw = cookies.get('auth_return_to') || '/dashboard';
113+
const returnTo = validateReturnTo(returnToRaw) ? returnToRaw : '/dashboard';
105114
cookies.delete('auth_state', { path: '/' });
106115
cookies.delete('auth_return_to', { path: '/' });
107116

src/routes/api/auth/callback/google/+server.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import { redirect } from '@sveltejs/kit';
1+
import { redirect, json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
33
import { signToken, generateId, slugify } from '$lib/server/auth';
4+
import { checkRateLimit, getRateLimitKey, RATE_LIMITS } from '$lib/server/rate-limit';
5+
import { validateReturnTo } from '$lib/server/validation';
46

57
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
68
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
79

8-
export const GET: RequestHandler = async ({ url, platform, cookies }) => {
10+
export const GET: RequestHandler = async ({ url, platform, cookies, request }) => {
911
const env = platform?.env;
1012
if (!env) throw new Error('Platform env not available');
1113

14+
const clientIp = request.headers.get('cf-connecting-ip') || 'unknown';
15+
const rl = checkRateLimit(getRateLimitKey('auth-callback-google', clientIp), RATE_LIMITS.AUTH_CALLBACK);
16+
if (!rl.allowed) {
17+
return json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfter! / 1000)) } });
18+
}
19+
1220
const code = url.searchParams.get('code');
1321
const state = url.searchParams.get('state');
1422
const savedState = cookies.get('auth_state');
@@ -103,7 +111,8 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => {
103111
maxAge: thirtyDays
104112
});
105113

106-
const returnTo = cookies.get('auth_return_to') || '/dashboard';
114+
const returnToRaw = cookies.get('auth_return_to') || '/dashboard';
115+
const returnTo = validateReturnTo(returnToRaw) ? returnToRaw : '/dashboard';
107116
cookies.delete('auth_state', { path: '/' });
108117
cookies.delete('auth_return_to', { path: '/' });
109118

0 commit comments

Comments
 (0)