补强静态发布安全边界

This commit is contained in:
2026-08-10 18:27:53 +08:00
parent 4df047708b
commit 8dd99c1855
15 changed files with 560 additions and 85 deletions

View File

@@ -3,6 +3,10 @@ import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
import { hasRendererCapability } from '../renderer-capability';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import {
trustedWorksProjectPlayUrl,
trustedWorksReleasePreviewUrl,
} from '../works-play-url';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
@@ -80,46 +84,6 @@ function normalizedWorksBaseUrl(): URL {
return new URL(`${base}/`);
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
return normalized === 'localhost'
|| normalized === '::1'
|| normalized === '0.0.0.0'
|| normalized.startsWith('127.');
}
function trustedLaunchUrl(value: string, worksBase: URL): string | null {
try {
const target = new URL(value, worksBase);
const absoluteUrl = target.toString();
if (
absoluteUrl.length > 1_024
|| worksBase.protocol !== 'https:'
|| target.protocol !== 'https:'
|| target.origin !== worksBase.origin
|| Boolean(target.username || target.password)
|| isLoopbackHostname(target.hostname)
) {
return null;
}
return absoluteUrl;
} catch {
return null;
}
}
function trustedReleasePreviewUrl(
value: string,
worksBase: URL,
releaseId: string,
): string | null {
const trusted = trustedLaunchUrl(value, worksBase);
if (!trusted) return null;
const target = new URL(trusted);
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
return target.pathname.startsWith(expectedPrefix) ? trusted : null;
}
async function requireManagedAccessToken(): Promise<string> {
const accessToken = await getValidWorksSquareAccessToken();
if (!accessToken) {
@@ -294,7 +258,11 @@ async function resolveDevicePreview(
const targetVersionMatches = remote.latestVersionId === deployment.version_id
&& remote.latestVersionName === deployment.version_name;
const launchUrl = remote.runtimeUrl
? trustedLaunchUrl(remote.runtimeUrl, normalizedWorksBaseUrl())
? trustedWorksProjectPlayUrl(
remote.runtimeUrl,
normalizedWorksBaseUrl(),
deployment.app_id,
)
: null;
if (!targetVersionMatches) {
@@ -334,7 +302,7 @@ async function resolveDevicePreview(
remote.latestReleaseId,
accessToken,
);
const previewUrl = trustedReleasePreviewUrl(
const previewUrl = trustedWorksReleasePreviewUrl(
rawPreviewUrl,
normalizedWorksBaseUrl(),
remote.latestReleaseId,

View File

@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { hasRendererCapability } from '../renderer-capability';
import { trustedWorksProjectPlayUrl } from '../works-play-url';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import {
@@ -201,7 +203,6 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
'buddy_name',
'buddy_sprite_url',
'buddy_pose_url',
'version_name',
'testing_ask',
'update_note',
'remix_note',
@@ -212,10 +213,45 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
const fieldValue = value[field];
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
}
if (typeof value.playable === 'boolean') projected.playable = value.playable;
const versionName = readOptionalString(value.version_name);
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
projected.playable = false;
projected.play_url = null;
projected.runtime_url = null;
if (value.playable === true && versionName) {
const playUrlPresent = value.play_url !== undefined && value.play_url !== null;
const primaryPlayUrl = readOptionalString(value.play_url);
const fallbackRuntimeUrl = readOptionalString(value.runtime_url);
const candidate = playUrlPresent ? primaryPlayUrl : fallbackRuntimeUrl;
const trusted = candidate
? trustedWorksProjectPlayUrl(
candidate,
new URL(`${normalizeWorksBase()}/`),
appId,
)
: null;
if (trusted) {
projected.playable = true;
if (playUrlPresent) projected.play_url = trusted;
else projected.runtime_url = trusted;
}
}
return projected;
}
function projectSafeProjectPage(value: unknown): Record<string, unknown> | null {
if (!isRecord(value) || !Array.isArray(value.items)) return null;
const items = value.items.map(projectSafeProject);
if (items.some((item) => item === null)) return null;
const nextCursor = readNullableStringField(value, 'next_cursor');
if (nextCursor === undefined || typeof value.limit !== 'number' || !Number.isFinite(value.limit)) {
return null;
}
return { items, next_cursor: nextCursor, limit: value.limit };
}
function projectSafeVersion(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const id = readOptionalString(value.id);
@@ -296,7 +332,12 @@ async function handleListProjects(res: ServerResponse, url: URL): Promise<void>
return;
}
sendJson(res, 200, { success: true, page: await readResponsePayload(response) });
const page = projectSafeProjectPage(await readResponsePayload(response));
if (!page) {
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
return;
}
sendJson(res, 200, { success: true, page });
}
async function handleGetProject(res: ServerResponse, appId: string): Promise<void> {
@@ -307,7 +348,12 @@ async function handleGetProject(res: ServerResponse, appId: string): Promise<voi
return;
}
sendJson(res, 200, { success: true, project: await readResponsePayload(response) });
const project = projectSafeProject(await readResponsePayload(response));
if (!project) {
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
return;
}
sendJson(res, 200, { success: true, project });
}
async function handleListAssets(res: ServerResponse, url: URL): Promise<void> {
@@ -433,7 +479,12 @@ async function handleCreateProject(req: IncomingMessage, res: ServerResponse): P
return;
}
sendJson(res, response.status, { success: true, project: await readResponsePayload(response) });
const project = projectSafeProject(await readResponsePayload(response));
if (!project) {
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
return;
}
sendJson(res, response.status, { success: true, project });
}
async function handleListMyProjects(
@@ -457,7 +508,12 @@ async function handleListMyProjects(
return;
}
sendJson(res, response.status, { success: true, page: await readResponsePayload(response) });
const page = projectSafeProjectPage(await readResponsePayload(response));
if (!page) {
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
return;
}
sendJson(res, response.status, { success: true, page });
}
async function handleGetBillingTokenUsage(
@@ -619,6 +675,10 @@ async function handleGetMyProjectStatus(
const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交';
const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]);
const VERSION_FILE_MAX_BYTES = 64 * 1024;
const LOCAL_PREVIEW_BINDING_WARNING = {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
} as const;
function createFallbackVersionName(now = new Date()): string {
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
@@ -841,6 +901,7 @@ async function handlePublishProjectSource(
);
return;
}
let bindingWarning: typeof LOCAL_PREVIEW_BINDING_WARNING | undefined;
if (ctx.worksSubmissionBinding) {
try {
await ctx.worksSubmissionBinding.recordSubmitted(projectId, {
@@ -852,13 +913,17 @@ async function handlePublishProjectSource(
});
} catch {
logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved');
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
}
} else {
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
}
const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary;
sendJson(res, uploadResponse.status, {
success: true,
package: rendererPackageSummary,
upload: uploadPayload,
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
@@ -1005,6 +1070,15 @@ export async function handleWorksRoutes(
}
if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') {
if (!hasRendererCapability(req)) {
sendJson(res, 403, {
success: false,
status: 403,
code: 'RENDERER_CAPABILITY_REQUIRED',
error: 'Renderer capability required',
});
return true;
}
await handlePublishProjectSource(req, res, ctx);
return true;
}

View File

@@ -0,0 +1,62 @@
const MAX_WORKS_PLAY_URL_LENGTH = 1_024;
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
return normalized === 'localhost'
|| normalized.endsWith('.localhost')
|| normalized === '::'
|| normalized === '::1'
|| normalized.startsWith('::ffff:127.')
|| normalized.startsWith('::ffff:7f00:')
|| normalized === '0.0.0.0'
|| normalized.startsWith('127.');
}
function trustedWorksHttpsUrl(value: string, worksBase: URL): URL | null {
try {
const target = new URL(value, worksBase);
if (
target.toString().length > MAX_WORKS_PLAY_URL_LENGTH
|| worksBase.protocol !== 'https:'
|| target.protocol !== 'https:'
|| target.origin !== worksBase.origin
|| Boolean(target.username || target.password)
|| isLoopbackHostname(target.hostname)
) {
return null;
}
return target;
} catch {
return null;
}
}
export function trustedWorksProjectPlayUrl(
value: string,
worksBase: URL,
appId: string,
): string | null {
const normalizedAppId = appId.trim();
if (!normalizedAppId) return null;
const target = trustedWorksHttpsUrl(value, worksBase);
if (
!target
|| target.pathname !== `/apps/${encodeURIComponent(normalizedAppId)}/`
|| target.search
|| target.hash
) {
return null;
}
return target.toString();
}
export function trustedWorksReleasePreviewUrl(
value: string,
worksBase: URL,
releaseId: string,
): string | null {
const target = trustedWorksHttpsUrl(value, worksBase);
if (!target) return null;
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
return target.pathname.startsWith(expectedPrefix) ? target.toString() : null;
}