feat(security): закрыть legacy-активы от публичного доступа

This commit is contained in:
2026-10-28 13:24:38 +03:00
parent 97149fa59d
commit 08e5a62c0e
5 changed files with 95 additions and 9 deletions

View File

@@ -65,6 +65,16 @@ class AssetStorage(
} }
} }
/** Имена обычных файлов непосредственно в корне хранилища (без подкаталогов). */
fun listNames(): List<String> =
Files.list(rootDir).use { stream ->
stream
.filter { Files.isRegularFile(it) }
.map { it.fileName.toString() }
.sorted()
.toList()
}
fun read(publicName: String): Pair<Path, String>? { fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/') val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize() val target = rootDir.resolve(clean).normalize()

View File

@@ -113,6 +113,7 @@ class ContentService(
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val articleAssets: ArticleAssetRepository, private val articleAssets: ArticleAssetRepository,
private val importer: ContentImporter, private val importer: ContentImporter,
private val assetStorage: AssetStorage,
) { ) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */ /** Публичное дерево: только глобальные главы с аудиторией ALL. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -198,6 +199,19 @@ class ContentService(
.filter { it.matchesAssetName(name) } .filter { it.matchesAssetName(name) }
.mapNotNull { assetAccess(it) } .mapNotNull { assetAccess(it) }
/**
* Файлы в хранилище, на которые не ссылается ни одна запись `article_assets`.
* Нужны админу для очистки legacy-файлов: по умолчанию они не публичны.
*/
@Transactional(readOnly = true)
fun unlinkedAssetNames(): List<String> {
val assets = articleAssets.findAll()
return assetStorage
.listNames()
.filter { name -> assets.none { it.matchesAssetName(name) } }
.sorted()
}
private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? { private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? {
val articleId = asset.articleId ?: return null val articleId = asset.articleId ?: return null
val article = articles.findById(articleId).orElse(null) val article = articles.findById(articleId).orElse(null)
@@ -215,9 +229,6 @@ class ContentService(
} }
} }
private fun ArticleAsset.matchesAssetName(name: String): Boolean =
filename == name || storedPath == name || storedPath.substringAfterLast('/') == name
private fun articleView( private fun articleView(
ch: ContentChapter, ch: ContentChapter,
sectionSlug: String, sectionSlug: String,
@@ -362,3 +373,10 @@ class ContentService(
sync: Boolean = true, sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync) ): ContentImportResult = importer.importZip(filename, bytes, sync)
} }
/**
* Единое правило связи файла с записью `article_assets`: совпадение имени файла,
* полного сохранённого пути или его basename.
*/
internal fun ArticleAsset.matchesAssetName(name: String): Boolean =
filename == name || storedPath == name || storedPath.substringAfterLast('/') == name

View File

@@ -71,6 +71,10 @@ class ContentAdminController(
@GetMapping("/tree/all") @GetMapping("/tree/all")
fun treeAll() = contentService.treeAll() fun treeAll() = contentService.treeAll()
/** Файлы хранилища без связи со статьями — для очистки legacy-активов. */
@GetMapping("/legacy-assets")
fun legacyAssets(): List<String> = contentService.unlinkedAssetNames()
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */ /** Просмотр статьи без учёта публикации — для предпросмотра админом. */
@GetMapping("/all/{chapter}/{section}/{article}") @GetMapping("/all/{chapter}/{section}/{article}")
fun articleAll( fun articleAll(

View File

@@ -25,8 +25,9 @@ import java.util.UUID
* Публичное чтение учебных материалов. * Публичное чтение учебных материалов.
* *
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации. * `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
* Активы без записи в `article_assets` исторически публичны (legacy-ссылки); * Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
* привязанные активы наследуют видимость статьи, раздела и главы. * их может скачать только админ для очистки. Привязанные активы наследуют
* видимость статьи, раздела и главы.
*/ */
@RestController @RestController
@RequestMapping("/api/v1/content") @RequestMapping("/api/v1/content")
@@ -63,7 +64,11 @@ class ContentPublicController(
@AuthenticationPrincipal jwt: Jwt?, @AuthenticationPrincipal jwt: Jwt?,
): ResponseEntity<FileSystemResource> { ): ResponseEntity<FileSystemResource> {
val accesses = contentService.assetAccesses(name) val accesses = contentService.assetAccesses(name)
if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt) if (accesses.isEmpty()) {
requireLegacyAssetAccess(jwt)
} else {
requireAssetAccess(accesses, jwt)
}
val (path, mime) = val (path, mime) =
assetStorage.read(name) assetStorage.read(name)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
@@ -84,6 +89,17 @@ class ContentPublicController(
return builder.body(FileSystemResource(path)) return builder.body(FileSystemResource(path))
} }
/**
* Legacy-файл без связи со статьёй не публичен: аноним и обычный пользователь
* получают 404, чтобы не раскрывать существование файла. Админ может скачать
* его для очистки хранилища.
*/
private fun requireLegacyAssetAccess(jwt: Jwt?) {
if (jwt?.getClaimAsString("role") != CourseAccessService.ROLE_ADMIN) {
throw NotFoundException("Файл не найден")
}
}
/** /**
* Аноним видит только глобальный опубликованный актив аудитории ALL. * Аноним видит только глобальный опубликованный актив аудитории ALL.
* Аутентифицированный — если видима хотя бы одна связанная статья. * Аутентифицированный — если видима хотя бы одна связанная статья.

View File

@@ -41,7 +41,7 @@ import java.util.UUID
/** /**
* Наследуемая видимость активов: курсовой актив требует доступ к курсу, * Наследуемая видимость активов: курсовой актив требует доступ к курсу,
* глобальный ALL-актив публичен, а старые несвязанные файлы остаются публичными. * глобальный ALL-актив публичен, а старые несвязанные файлы доступны только админу.
*/ */
@Testcontainers @Testcontainers
@SpringBootTest @SpringBootTest
@@ -81,6 +81,9 @@ class ContentAssetAccessIntegrationTest {
@Autowired @Autowired
private lateinit var articleAssets: ArticleAssetRepository private lateinit var articleAssets: ArticleAssetRepository
@Autowired
private lateinit var contentService: ContentService
@MockitoBean @MockitoBean
private lateinit var identity: IdentityClient private lateinit var identity: IdentityClient
@@ -199,12 +202,47 @@ class ContentAssetAccessIntegrationTest {
} }
@Test @Test
fun `legacy asset without article association stays public`() { fun `legacy asset without article association is not public`() {
val name = storeAsset() val name = storeAsset()
mockMvc mockMvc
.perform(get("/api/v1/content/assets/{name}", name)) .perform(get("/api/v1/content/assets/{name}", name))
.andExpect(status().isOk) .andExpect(status().isNotFound)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isNotFound)
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
).andExpect(status().isNotFound)
val response =
mockMvc
.perform(
get("/api/v1/content/assets/{name}", name)
.with(jwtFor(UUID.randomUUID(), "ADMIN")),
).andExpect(status().isOk)
.andReturn()
.response
assertThat(response.contentAsByteArray).isEqualTo(ASSET_BYTES)
}
@Test
fun `unlinkedAssetNames lists stored file until it is linked`() {
val name = storeAsset()
assertThat(contentService.unlinkedAssetNames()).contains(name)
val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL))
link(material.article, name)
assertThat(contentService.unlinkedAssetNames()).doesNotContain(name)
} }
@Test @Test