Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// Public known-device probe request budget per minute (per client identifier).
// 公开 known-device 探测接口每分钟请求配额(按客户端标识)。
knownDeviceProbeRequestsPerMinute: 10,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
Expand Down
22 changes: 20 additions & 2 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Env, DEFAULT_DEV_SECRET } from './types';
import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { RateLimitService, getClientIdentifier, getClientIp } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { LIMITS } from './config/limits';

Expand Down Expand Up @@ -212,8 +212,26 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return new Response(null, { status: 200 });
}

// Known device check (no auth required)
// Known device check (no auth required, but strictly rate-limited)
if (path === '/api/devices/knowndevice' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
const clientIp = getClientIp(request) || 'unknown-ip';
const rateLimitCheck = await rateLimit.consumeKnownDeviceProbeBudget(clientIp + ':known-device');

if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Known-device probe rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}

return handleKnownDevice(request, env);
}

Expand Down
17 changes: 16 additions & 1 deletion src/services/ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const CONFIG = {
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
// Dedicated budget for public known-device probes.
KNOWN_DEVICE_PROBE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceProbeRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};

Expand Down Expand Up @@ -222,14 +224,27 @@ export class RateLimitService {
CONFIG.API_WINDOW_SECONDS
);
}

// Read budget for unauthenticated GET /api/devices/knowndevice.
async consumeKnownDeviceProbeBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.KNOWN_DEVICE_PROBE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
}

export function getClientIdentifier(request: Request): string {
return getClientIp(request) || 'unknown';
}

export function getClientIp(request: Request): string | null {
const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp;

const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim();

return 'unknown';
return null;
}
54 changes: 47 additions & 7 deletions src/setup/pageTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
<span id="copyTotpBtnText">Copy</span>
</button>
</div>
<p class="hint" id="t_s5_uri_hint" style="margin-top:10px;"></p>
<div class="server" id="totpUri"></div>
<div class="totp-preview" id="totpPreview">
<div class="totp-code" id="totpCodeDisplay">------</div>
<div class="totp-expire" id="totpExpireText"></div>
Expand Down Expand Up @@ -678,6 +680,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js" referrerpolicy="no-referrer"></script>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading external scripts from a CDN without Subresource Integrity (SRI) creates a security risk. If the CDN is compromised or serves malicious content, it could execute arbitrary code in users' browsers during the setup process. Consider either: (1) adding an integrity attribute with the SHA hash of the expected file, or (2) bundling the qrcode-generator library locally instead of loading it from a CDN.

Suggested change
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js" referrerpolicy="no-referrer"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js" referrerpolicy="no-referrer" integrity="sha384-REPLACE_WITH_ACTUAL_HASH_FOR_QRCODE_GENERATOR_1_4_4" crossorigin="anonymous"></script>

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading the QR code library from cdn.jsdelivr.net may fail in regions where jsdelivr is blocked (e.g., some corporate networks or countries with strict internet policies). When the CDN is blocked, the code falls back to a placeholder, but users won't be able to scan the QR code. Consider: (1) documenting this limitation in user-facing messaging, or (2) bundling the library locally to ensure it always loads.

Copilot uses AI. Check for mistakes.
<script>
const JWT_STATE = ${jwtStateJson};

Expand Down Expand Up @@ -755,6 +758,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
s5Enable2: '新增 Secret:TOTP_SECRET,值填写下方生成的 Base32 密钥。',
s5QrTitle: '扫描二维码',
s5UriHint: '如需手动导入,请复制以下 otpauth 链接:',
copyCode: '复制验证码',
totpExpire: '秒后过期',
s6Title: '最终页面',
Expand Down Expand Up @@ -841,6 +845,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
s5Enable2: 'Add Secret: TOTP_SECRET, using the generated Base32 seed below.',
s5QrTitle: 'Scan QR code',
s5UriHint: 'For manual import, copy this otpauth URI:',
copyCode: 'Copy code',
totpExpire: 's left',
s6Title: 'Final step',
Expand Down Expand Up @@ -951,6 +956,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
setText('t_s5_enable_1', t('s5Enable1'));
setText('t_s5_enable_2', t('s5Enable2'));
setText('t_s5_qr_title', t('s5QrTitle'));
setText('t_s5_uri_hint', t('s5UriHint'));
setText('refreshTotpBtnText', t('refresh'));
setText('copyTotpBtnText', t('copy'));
setText('copyTotpCodeBtnText', t('copyCode'));
Expand Down Expand Up @@ -1039,16 +1045,50 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
+ '&algorithm=SHA1&digits=6&period=30';
}

function renderTotpQrPlaceholder() {
const qr = document.getElementById('totpQr');
if (!qr) return;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="170" height="170" viewBox="0 0 170 170" role="img" aria-label="Local-only TOTP setup">'
+ '<rect width="170" height="170" fill="#ffffff"/>'
+ '<rect x="10" y="10" width="150" height="150" rx="10" fill="#f8fafc" stroke="#d5dae1"/>'
+ '<path d="M85 46a16 16 0 0 0-16 16v8h32v-8a16 16 0 0 0-16-16Zm-9 24v-8a9 9 0 1 1 18 0v8h-18Zm-7 8h32v30H69V78Zm16 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z" fill="#111418"/>'
+ '<text x="85" y="130" text-anchor="middle" font-size="10" font-family="Arial, sans-serif" fill="#344054">LOCAL ONLY</text>'
+ '<text x="85" y="143" text-anchor="middle" font-size="9" font-family="Arial, sans-serif" fill="#667085">Use seed / otpauth URI</text>'
+ '</svg>';
qr.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}

function renderTotpQrFromUri(uri) {
const qr = document.getElementById('totpQr');
if (!qr) return;

try {
if (typeof qrcode === 'function') {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check 'typeof qrcode === "function"' may not be reliable across all scenarios. While qrcode-generator does expose a constructor function, a more defensive check would be 'typeof qrcode !== "undefined" && qrcode' to handle edge cases where the library loads but the global is malformed. Alternatively, check for the specific API: 'typeof qrcode === "function" && typeof qrcode(0, "M").addData === "function"' to ensure the expected API is available.

Suggested change
if (typeof qrcode === 'function') {
if (typeof qrcode !== 'undefined' && qrcode) {

Copilot uses AI. Check for mistakes.
const qrCode = qrcode(0, 'M');
qrCode.addData(uri, 'Byte');
qrCode.make();
qr.src = qrCode.createDataURL(4, 0);
return;
}
} catch {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty catch block silently swallows all errors that occur during QR code generation. While the code does fall back to the placeholder, logging the error would help with debugging issues in production. Consider adding a console.error or console.warn statement to log the exception.

Suggested change
} catch {
} catch (err) {
console.error('Failed to render TOTP QR code from URI.', err);

Copilot uses AI. Check for mistakes.
// Fallback to local placeholder if runtime QR generation fails.
}

renderTotpQrPlaceholder();
}

function renderTotpUri(uri) {
const uriEl = document.getElementById('totpUri');
if (uriEl) uriEl.textContent = uri;
}

function renderTotpHelper(seed) {
const seedEl = document.getElementById('totpSeed');
if (seedEl) seedEl.value = seed;

const uri = buildTotpUri(seed);
const qr = document.getElementById('totpQr');
if (qr) {
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
qr.src = qrUrl;
}
renderTotpUri(uri);
renderTotpQrFromUri(uri);

const preview = document.getElementById('totpPreview');
if (preview) preview.style.display = 'flex';
Expand All @@ -1067,8 +1107,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
const seed = el ? el.value.trim() : '';
if (!seed) return;
const uri = buildTotpUri(seed);
const qr = document.getElementById('totpQr');
if (qr) qr.src = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
renderTotpUri(uri);
renderTotpQrFromUri(uri);
const preview = document.getElementById('totpPreview');
if (preview) preview.style.display = 'flex';
startTotpTick();
Expand Down