@zeropress/build-core 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "node": ">=18.18.0"
34
34
  },
35
35
  "dependencies": {
36
+ "@zeropress/slug-policy": "^0.1.0",
36
37
  "@zeropress/preview-data-validator": "^0.4.1",
37
38
  "@zeropress/theme-validator": "^0.3.0",
38
39
  "highlight.js": "^11.11.1",
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { assertPreviewData } from '@zeropress/preview-data-validator';
3
+ import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
3
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
4
5
  import { AssetProcessor } from '../assets/asset-processor.js';
5
6
  import { renderDocumentContent } from '../render/content-renderer.js';
@@ -17,9 +18,11 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
17
18
  const DEFAULT_TIME_FORMAT = 'HH:mm';
18
19
  const DEFAULT_TIMEZONE = 'UTC';
19
20
  const DEFAULT_LOCALE = 'en-US';
21
+ const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
20
22
  export async function buildSite(input) {
21
23
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
22
24
  const state = await createBuildState(input, options);
25
+ assertPlannedOutputPathsSafe(state);
23
26
 
24
27
  for (const route of state.renderData.indexRoutes) {
25
28
  await renderRoute(state, 'index', route);
@@ -959,7 +962,9 @@ function isAbsoluteUrl(value) {
959
962
  }
960
963
 
961
964
  async function writeOutput(writer, summaries, path, content, contentType) {
962
- const normalizedPath = normalizeOutputPath(path);
965
+ const rawPath = String(path || '');
966
+ const normalizedPath = normalizeOutputPath(rawPath);
967
+ assertSafeRelativeOutputPath(rawPath, normalizedPath);
963
968
  await writer.write({ path: normalizedPath, content, contentType });
964
969
  summaries.push({
965
970
  path: normalizedPath,
@@ -989,6 +994,95 @@ function normalizeOutputPath(filePath) {
989
994
  return String(filePath || '').replace(/^\/+/, '');
990
995
  }
991
996
 
997
+ function assertPlannedOutputPathsSafe(state) {
998
+ for (const post of state.renderData.posts) {
999
+ assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
1000
+ }
1001
+
1002
+ for (const page of state.renderData.pages) {
1003
+ assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
1004
+ }
1005
+
1006
+ for (const category of state.previewData.content.categories) {
1007
+ assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
1008
+ }
1009
+
1010
+ for (const tag of state.previewData.content.tags) {
1011
+ assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
1012
+ }
1013
+
1014
+ const plannedPaths = [
1015
+ ...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
1016
+ ...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
1017
+ ...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
1018
+ ...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
1019
+ ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1020
+ ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1021
+ ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1022
+ ];
1023
+
1024
+ if (state.options.generateSpecialFiles) {
1025
+ plannedPaths.push('404.html', 'robots.txt', 'meta.json');
1026
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1027
+ plannedPaths.push('sitemap.xml', 'feed.xml');
1028
+ }
1029
+ }
1030
+
1031
+ for (const plannedPath of plannedPaths) {
1032
+ const rawPath = String(plannedPath || '');
1033
+ const normalizedPath = normalizeOutputPath(rawPath);
1034
+ assertSafeRelativeOutputPath(rawPath, normalizedPath);
1035
+ }
1036
+ }
1037
+
1038
+ function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
1039
+ const originalSlug = typeof rawSlug === 'string' ? rawSlug : '';
1040
+ const decodedSlug = encodeSlugSegment(rawSlug);
1041
+
1042
+ if (
1043
+ !isSafeSlugPathSegment(originalSlug) ||
1044
+ !isSafeSlugPathSegment(decodedSlug)
1045
+ ) {
1046
+ throw new Error(`Unsafe output path detected: ${outputPath}`);
1047
+ }
1048
+ }
1049
+
1050
+ function isSafeSlugPathSegment(value) {
1051
+ return isSafeSlugSegment(value);
1052
+ }
1053
+
1054
+ function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputPath(rawPath)) {
1055
+ const originalPath = String(rawPath || '');
1056
+ const candidatePath = String(normalizedPath || '');
1057
+ const normalizedSeparators = candidatePath.replace(/\\/g, '/');
1058
+
1059
+ if (originalPath.trim() === '' || candidatePath.trim() === '') {
1060
+ throw new Error(`Unsafe output path detected: ${originalPath || candidatePath || '<empty>'}`);
1061
+ }
1062
+
1063
+ if (OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(originalPath) || OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(candidatePath)) {
1064
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1065
+ }
1066
+
1067
+ if (candidatePath.includes('%')) {
1068
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1069
+ }
1070
+
1071
+ if (
1072
+ originalPath.startsWith('/') ||
1073
+ originalPath.startsWith('\\') ||
1074
+ /^[A-Za-z]:[\\/]/.test(originalPath) ||
1075
+ normalizedSeparators.startsWith('//')
1076
+ ) {
1077
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1078
+ }
1079
+
1080
+ const segments = normalizedSeparators.split('/');
1081
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
1082
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1083
+ }
1084
+ }
1085
+
992
1086
  function getContentSize(content) {
993
1087
  return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
994
1088
  }
@@ -1120,23 +1214,6 @@ function encodeSlugSegment(slug) {
1120
1214
  return normalizeStoredSlug(slug);
1121
1215
  }
1122
1216
 
1123
- function normalizeStoredSlug(slug) {
1124
- if (typeof slug !== 'string') {
1125
- return '';
1126
- }
1127
-
1128
- const trimmed = slug.trim();
1129
- if (!trimmed.includes('%')) {
1130
- return trimmed;
1131
- }
1132
-
1133
- try {
1134
- return decodeURIComponent(trimmed);
1135
- } catch {
1136
- return trimmed;
1137
- }
1138
- }
1139
-
1140
1217
  function decodeRoutePath(routePath) {
1141
1218
  try {
1142
1219
  return decodeURI(routePath);