Merge pull request #285 from usbharu/feature/validate-http-header-with-http-signature

HTTP Signatureに関連したHTTPヘッダーの検査を行うように
This commit is contained in:
usbharu 2024-02-21 00:22:53 +09:00 committed by GitHub
commit cde1943711
10 changed files with 871 additions and 118 deletions

View File

@ -27,6 +27,7 @@ import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.TestFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@ -38,6 +39,7 @@ import org.springframework.transaction.annotation.Transactional
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@Transactional
@Disabled
class InboxCommonTest {
@LocalServerPort
private var port = ""

View File

@ -17,11 +17,13 @@
package activitypub.inbox
import dev.usbharu.hideout.SpringApplication
import dev.usbharu.hideout.util.Base64Util
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
@ -37,17 +39,25 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import util.TestTransaction
import util.WithMockHttpSignature
import java.security.MessageDigest
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@SpringBootTest(classes = [SpringApplication::class])
@AutoConfigureMockMvc
@Transactional
class InboxTest {
@Autowired
@Qualifier("http")
private lateinit var dateTimeFormatter: DateTimeFormatter
@Autowired
private lateinit var context: WebApplicationContext
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context)
@ -62,6 +72,12 @@ class InboxTest {
.post("/inbox") {
content = "{}"
contentType = MediaType.APPLICATION_JSON
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header(
"Digest",
"SHA-256=" + Base64Util.encode(MessageDigest.getInstance("SHA-256").digest("{}".toByteArray()))
)
}
.asyncDispatch()
.andExpect { status { isUnauthorized() } }
@ -74,7 +90,13 @@ class InboxTest {
.post("/inbox") {
content = "{}"
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header(
"Digest",
"SHA-256=" + Base64Util.encode(MessageDigest.getInstance("SHA-256").digest("{}".toByteArray()))
)
}
.asyncDispatch()
.andExpect { status { isAccepted() } }
@ -87,8 +109,15 @@ class InboxTest {
.post("/users/hoge/inbox") {
content = "{}"
contentType = MediaType.APPLICATION_JSON
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header(
"Digest",
"SHA-256=" + Base64Util.encode(MessageDigest.getInstance("SHA-256").digest("{}".toByteArray()))
)
}
.asyncDispatch()
.andDo { print() }
.andExpect { status { isUnauthorized() } }
}
@ -99,9 +128,16 @@ class InboxTest {
.post("/users/hoge/inbox") {
content = "{}"
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header(
"Digest",
"SHA-256=" + Base64Util.encode(MessageDigest.getInstance("SHA-256").digest("{}".toByteArray()))
)
}
.asyncDispatch()
.andDo { print() }
.andExpect { status { isAccepted() } }
}

View File

@ -22,6 +22,7 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
@ -36,6 +37,8 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import util.WithHttpSignature
import util.WithMockHttpSignature
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@SpringBootTest(classes = [SpringApplication::class])
@AutoConfigureMockMvc
@ -46,6 +49,10 @@ class NoteTest {
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
@Qualifier("http")
private lateinit var dateTimeFormatter: DateTimeFormatter
@BeforeEach
fun setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply<DefaultMockMvcBuilder>(springSecurity()).build()
@ -197,6 +204,29 @@ class NoteTest {
.andExpect { jsonPath("\$.attachment[1].url") { value("https://example.com/media/test-media2.png") } }
}
@Test
fun signatureヘッダーがあるのにhostヘッダーがないと401() {
mockMvc
.get("/users/test-user10/posts/9999") {
accept(MediaType("application", "activity+json"))
header("Signature", "a")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
}
.andExpect { status { isUnauthorized() } }
}
@Test
fun signatureヘッダーがあるのにdateヘッダーがないと401() {
mockMvc
.get("/users/test-user10/posts/9999") {
accept(MediaType("application", "activity+json"))
header("Signature", "a")
header("Host", "example.com")
}
.andExpect { status { isUnauthorized() } }
}
companion object {
@JvmStatic
@AfterAll

View File

@ -16,8 +16,8 @@
package dev.usbharu.hideout.activitypub.interfaces.api.inbox
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController
@ -34,5 +34,5 @@ interface InboxController {
consumes = ["application/json", "application/*+json"],
method = [RequestMethod.POST]
)
suspend fun inbox(@RequestBody string: String): ResponseEntity<Unit>
suspend fun inbox(httpServletRequest: HttpServletRequest): ResponseEntity<String>
}

View File

@ -17,67 +17,65 @@
package dev.usbharu.hideout.activitypub.interfaces.api.inbox
import dev.usbharu.hideout.activitypub.service.common.APService
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureHeaderChecker
import dev.usbharu.httpsignature.common.HttpHeaders
import dev.usbharu.httpsignature.common.HttpMethod
import dev.usbharu.httpsignature.common.HttpRequest
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.slf4j.MDCContext
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders.WWW_AUTHENTICATE
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.net.URL
@RestController
class InboxControllerImpl(private val apService: APService) : InboxController {
class InboxControllerImpl(
private val apService: APService,
private val httpSignatureHeaderChecker: HttpSignatureHeaderChecker,
) : InboxController {
@Suppress("TooGenericExceptionCaught")
override suspend fun inbox(
@RequestBody string: String
): ResponseEntity<Unit> {
val request = (requireNotNull(RequestContextHolder.getRequestAttributes()) as ServletRequestAttributes).request
val headersList = request.headerNames?.toList().orEmpty()
httpServletRequest: HttpServletRequest,
): ResponseEntity<String> {
val headersList = httpServletRequest.headerNames?.toList().orEmpty()
LOGGER.trace("Inbox Headers {}", headersList)
if (headersList.map { it.lowercase() }.contains("signature").not()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.header(
WWW_AUTHENTICATE,
"Signature realm=\"Example\",headers=\"(request-target) date host digest\""
)
.build()
val body = withContext(Dispatchers.IO + MDCContext()) {
httpServletRequest.inputStream.readAllBytes()!!
}
val responseEntity = checkHeader(httpServletRequest, body)
if (responseEntity != null) {
return responseEntity
}
val parseActivity = try {
apService.parseActivity(string)
apService.parseActivity(body.decodeToString())
} catch (e: Exception) {
LOGGER.warn("FAILED Parse Activity", e)
return ResponseEntity.accepted().build()
}
LOGGER.info("INBOX Processing Activity Type: {}", parseActivity)
try {
val url = request.requestURL.toString()
val url = httpServletRequest.requestURL.toString()
val headers =
headersList.associateWith { header -> request.getHeaders(header)?.toList().orEmpty() }
val method = when (val method = request.method.lowercase()) {
"get" -> HttpMethod.GET
"post" -> HttpMethod.POST
else -> {
throw IllegalArgumentException("Unsupported method: $method")
}
headersList.associateWith { header ->
httpServletRequest.getHeaders(header)?.toList().orEmpty()
}
apService.processActivity(
string,
body.decodeToString(),
parseActivity,
HttpRequest(
URL(url + request.queryString.orEmpty()),
URL(url + httpServletRequest.queryString.orEmpty()),
HttpHeaders(headers),
method
HttpMethod.GET
),
headers
)
@ -89,6 +87,46 @@ class InboxControllerImpl(private val apService: APService) : InboxController {
return ResponseEntity(HttpStatus.ACCEPTED)
}
private fun checkHeader(
httpServletRequest: HttpServletRequest,
body: ByteArray,
): ResponseEntity<String>? {
try {
httpSignatureHeaderChecker.checkDate(httpServletRequest.getHeader("date")!!)
} catch (_: NullPointerException) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Required date header")
} catch (_: IllegalArgumentException) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Request is too old.")
}
try {
httpSignatureHeaderChecker.checkHost(httpServletRequest.getHeader("host")!!)
} catch (_: NullPointerException) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Required host header")
} catch (_: IllegalArgumentException) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Wrong host for request")
}
try {
httpSignatureHeaderChecker.checkDigest(body, httpServletRequest.getHeader("digest")!!)
} catch (_: NullPointerException) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Required request body digest in digest header (sha256)")
} catch (_: IllegalArgumentException) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Wrong digest for request")
}
if (httpServletRequest.getHeader("signature").orEmpty().isBlank()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.header(
WWW_AUTHENTICATE,
"Signature realm=\"Example\",headers=\"(request-target) date host digest\""
)
.build()
}
return null
}
companion object {
private val LOGGER = LoggerFactory.getLogger(InboxControllerImpl::class.java)
}

View File

@ -26,6 +26,7 @@ import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.application.infrastructure.springframework.RoleHierarchyAuthorizationManagerFactory
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureFilter
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureHeaderChecker
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureUserDetailsService
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureVerifierComposite
import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.UserDetailsImpl
@ -35,6 +36,9 @@ import dev.usbharu.httpsignature.sign.RsaSha256HttpSignatureSigner
import dev.usbharu.httpsignature.verify.DefaultSignatureHeaderParser
import dev.usbharu.httpsignature.verify.RsaSha256HttpSignatureVerifier
import jakarta.annotation.PostConstruct
import jakarta.servlet.*
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer
import org.springframework.boot.context.properties.ConfigurationProperties
@ -58,6 +62,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolderStrategy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.core.AuthorizationGrantType
@ -67,14 +72,21 @@ import org.springframework.security.oauth2.server.authorization.config.annotatio
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer
import org.springframework.security.web.FilterChainProxy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.ExceptionTranslationFilter
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider
import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer
import org.springframework.security.web.debug.DebugFilter
import org.springframework.security.web.firewall.HttpFirewall
import org.springframework.security.web.firewall.RequestRejectedHandler
import org.springframework.security.web.savedrequest.RequestCacheAwareFilter
import org.springframework.security.web.util.matcher.AnyRequestMatcher
import org.springframework.web.filter.CompositeFilter
import java.io.IOException
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
@ -86,15 +98,14 @@ import java.util.*
class SecurityConfig {
@Bean
fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager? {
return authenticationConfiguration.authenticationManager
}
fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager? =
authenticationConfiguration.authenticationManager
@Bean
@Order(1)
fun httpSignatureFilterChain(
http: HttpSecurity,
httpSignatureFilter: HttpSignatureFilter
httpSignatureFilter: HttpSignatureFilter,
): SecurityFilterChain {
http {
securityMatcher("/users/*/posts/*")
@ -122,9 +133,10 @@ class SecurityConfig {
@Bean
fun getHttpSignatureFilter(
authenticationManager: AuthenticationManager,
httpSignatureHeaderChecker: HttpSignatureHeaderChecker,
): HttpSignatureFilter {
val httpSignatureFilter =
HttpSignatureFilter(DefaultSignatureHeaderParser())
HttpSignatureFilter(DefaultSignatureHeaderParser(), httpSignatureHeaderChecker)
httpSignatureFilter.setAuthenticationManager(authenticationManager)
httpSignatureFilter.setContinueFilterChainOnUnsuccessfulAuthentication(false)
val authenticationEntryPointFailureHandler =
@ -147,7 +159,7 @@ class SecurityConfig {
@Order(1)
fun httpSignatureAuthenticationProvider(
transaction: Transaction,
actorRepository: ActorRepository
actorRepository: ActorRepository,
): PreAuthenticatedAuthenticationProvider {
val provider = PreAuthenticatedAuthenticationProvider()
val signatureHeaderParser = DefaultSignatureHeaderParser()
@ -190,7 +202,7 @@ class SecurityConfig {
@Order(4)
fun defaultSecurityFilterChain(
http: HttpSecurity,
rf: RoleHierarchyAuthorizationManagerFactory
rf: RoleHierarchyAuthorizationManagerFactory,
): SecurityFilterChain {
http {
authorizeHttpRequests {
@ -401,6 +413,86 @@ class SecurityConfig {
return roleHierarchyImpl
}
// Spring Security 3.2.1 に存在する EnableWebSecurity(debug = true)にすると発生するエラーに対処するためのコード
// trueにしないときはコメントアウト
// @Bean
fun beanDefinitionRegistryPostProcessor(): BeanDefinitionRegistryPostProcessor {
return BeanDefinitionRegistryPostProcessor { registry: BeanDefinitionRegistry ->
registry.getBeanDefinition(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME).beanClassName =
CompositeFilterChainProxy::class.java.name
}
}
@Suppress("ExpressionBodySyntax")
internal class CompositeFilterChainProxy(filters: List<Filter?>) : FilterChainProxy() {
private val doFilterDelegate: Filter
private val springSecurityFilterChain: FilterChainProxy
init {
this.doFilterDelegate = createDoFilterDelegate(filters)
this.springSecurityFilterChain = findFilterChainProxy(filters)
}
override fun afterPropertiesSet() {
springSecurityFilterChain.afterPropertiesSet()
}
@Throws(IOException::class, ServletException::class)
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
doFilterDelegate.doFilter(request, response, chain)
}
override fun getFilters(url: String): List<Filter> {
return springSecurityFilterChain.getFilters(url)
}
override fun getFilterChains(): List<SecurityFilterChain> {
return springSecurityFilterChain.filterChains
}
override fun setSecurityContextHolderStrategy(securityContextHolderStrategy: SecurityContextHolderStrategy) {
springSecurityFilterChain.setSecurityContextHolderStrategy(securityContextHolderStrategy)
}
override fun setFilterChainValidator(filterChainValidator: FilterChainValidator) {
springSecurityFilterChain.setFilterChainValidator(filterChainValidator)
}
override fun setFilterChainDecorator(filterChainDecorator: FilterChainDecorator) {
springSecurityFilterChain.setFilterChainDecorator(filterChainDecorator)
}
override fun setFirewall(firewall: HttpFirewall) {
springSecurityFilterChain.setFirewall(firewall)
}
override fun setRequestRejectedHandler(requestRejectedHandler: RequestRejectedHandler) {
springSecurityFilterChain.setRequestRejectedHandler(requestRejectedHandler)
}
companion object {
private fun createDoFilterDelegate(filters: List<Filter?>): Filter {
val delegate: CompositeFilter = CompositeFilter()
delegate.setFilters(filters)
return delegate
}
private fun findFilterChainProxy(filters: List<Filter?>): FilterChainProxy {
for (filter in filters) {
if (filter is FilterChainProxy) {
return filter
}
if (filter is DebugFilter) {
return filter.filterChainProxy
}
}
throw IllegalStateException("Couldn't find FilterChainProxy in $filters")
}
}
}
}
@ConfigurationProperties("hideout.security.jwt")
@ -408,14 +500,14 @@ class SecurityConfig {
data class JwkConfig(
val keyId: String,
val publicKey: String,
val privateKey: String
val privateKey: String,
)
@Configuration
class PostSecurityConfig(
val auth: AuthenticationManagerBuilder,
val daoAuthenticationProvider: DaoAuthenticationProvider,
val httpSignatureAuthenticationProvider: PreAuthenticatedAuthenticationProvider
val httpSignatureAuthenticationProvider: PreAuthenticatedAuthenticationProvider,
) {
@PostConstruct

View File

@ -24,7 +24,10 @@ import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter
import java.net.URL
class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeaderParser) :
class HttpSignatureFilter(
private val httpSignatureHeaderParser: SignatureHeaderParser,
private val httpSignatureHeaderChecker: HttpSignatureHeaderChecker,
) :
AbstractPreAuthenticatedProcessingFilter() {
override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? {
val headersList = request?.headerNames?.toList().orEmpty()
@ -42,7 +45,7 @@ class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeader
return signature.keyId
}
override fun getPreAuthenticatedCredentials(request: HttpServletRequest?): Any {
override fun getPreAuthenticatedCredentials(request: HttpServletRequest?): Any? {
requireNotNull(request)
val url = request.requestURL.toString()
@ -55,10 +58,26 @@ class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeader
"get" -> HttpMethod.GET
"post" -> HttpMethod.POST
else -> {
throw IllegalArgumentException("Unsupported method: $method")
// throw IllegalArgumentException("Unsupported method: $method")
return null
}
}
try {
httpSignatureHeaderChecker.checkDate(request.getHeader("date")!!)
httpSignatureHeaderChecker.checkHost(request.getHeader("host")!!)
if (request.method.equals("post", true)) {
httpSignatureHeaderChecker.checkDigest(
request.inputStream.readAllBytes()!!,
request.getHeader("digest")!!
)
}
} catch (_: NullPointerException) {
return null
} catch (_: IllegalArgumentException) {
return null
}
return HttpRequest(
URL(url + request.queryString.orEmpty()),
HttpHeaders(headers),

View File

@ -0,0 +1,58 @@
/*
* Copyright (C) 2024 usbharu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.usbharu.hideout.core.infrastructure.springframework.httpsignature
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.util.Base64Util
import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.*
@Component
class HttpSignatureHeaderChecker(private val applicationConfig: ApplicationConfig) {
fun checkDate(date: String) {
val from = Instant.from(dateFormat.parse(date))
if (from.isAfter(Instant.now()) || from.isBefore(Instant.now().minusSeconds(86400))) {
throw IllegalArgumentException("未来")
}
}
fun checkHost(host: String) {
if (applicationConfig.url.host.equals(host, true).not()) {
throw IllegalArgumentException("ホスト名が違う")
}
}
fun checkDigest(byteArray: ByteArray, digest: String) {
val find = regex.find(digest)
val sha256 = MessageDigest.getInstance("SHA-256")
val other = find?.groups?.get(2)?.value.orEmpty()
if (Base64Util.encode(sha256.digest(byteArray)).equals(other, true).not()) {
throw IllegalArgumentException("リクエストボディが違う")
}
}
companion object {
private val dateFormat = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
private val regex = Regex("^([a-zA-Z0-9\\-]+)=(.+)$")
}
}

View File

@ -19,13 +19,17 @@ package dev.usbharu.hideout.activitypub.interfaces.api.inbox
import dev.usbharu.hideout.activitypub.domain.exception.JsonParseException
import dev.usbharu.hideout.activitypub.service.common.APService
import dev.usbharu.hideout.activitypub.service.common.ActivityType
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException
import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureHeaderChecker
import dev.usbharu.hideout.util.Base64Util
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Spy
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
import org.springframework.http.MediaType
@ -33,12 +37,21 @@ import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.net.URI
import java.security.MessageDigest
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
@ExtendWith(MockitoExtension::class)
class InboxControllerImplTest {
private lateinit var mockMvc: MockMvc
@Spy
private val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("https://example.com").toURL()))
@Mock
private lateinit var apService: APService
@ -50,6 +63,10 @@ class InboxControllerImplTest {
mockMvc = MockMvcBuilders.standaloneSetup(inboxController).build()
}
private val dateTimeFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
@Test
fun `inbox 正常なPOSTリクエストをしたときAcceptが返ってくる`() = runTest {
@ -58,22 +75,23 @@ class InboxControllerImplTest {
whenever(apService.parseActivity(eq(json))).doReturn(ActivityType.Follow)
whenever(
apService.processActivity(
eq(json),
eq(ActivityType.Follow),
any(),
any()
eq(json), eq(ActivityType.Follow), any(), any()
)
).doReturn(Unit)
mockMvc
.post("/inbox") {
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=" + digest)
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -83,15 +101,17 @@ class InboxControllerImplTest {
fun `inbox parseActivityに失敗したときAcceptが返ってくる`() = runTest {
val json = """{"type":"Hoge"}"""
whenever(apService.parseActivity(eq(json))).doThrow(JsonParseException::class)
val sha256 = MessageDigest.getInstance("SHA-256")
mockMvc
.post("/inbox") {
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -103,21 +123,20 @@ class InboxControllerImplTest {
whenever(apService.parseActivity(eq(json))).doReturn(ActivityType.Follow)
whenever(
apService.processActivity(
eq(json),
eq(ActivityType.Follow),
any(),
any()
eq(json), eq(ActivityType.Follow), any(), any()
)
).doThrow(FailedToGetResourcesException::class)
val sha256 = MessageDigest.getInstance("SHA-256")
mockMvc
.post("/inbox") {
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -137,15 +156,17 @@ class InboxControllerImplTest {
whenever(apService.processActivity(eq(json), eq(ActivityType.Follow), any(), any())).doReturn(
Unit
)
val sha256 = MessageDigest.getInstance("SHA-256")
mockMvc
.post("/users/hoge/inbox") {
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -155,15 +176,17 @@ class InboxControllerImplTest {
fun `user-inbox parseActivityに失敗したときAcceptが返ってくる`() = runTest {
val json = """{"type":"Hoge"}"""
whenever(apService.parseActivity(eq(json))).doThrow(JsonParseException::class)
val sha256 = MessageDigest.getInstance("SHA-256")
mockMvc
.post("/users/hoge/inbox") {
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -175,21 +198,20 @@ class InboxControllerImplTest {
whenever(apService.parseActivity(eq(json))).doReturn(ActivityType.Follow)
whenever(
apService.processActivity(
eq(json),
eq(ActivityType.Follow),
any(),
any()
eq(json), eq(ActivityType.Follow), any(), any()
)
).doThrow(FailedToGetResourcesException::class)
val sha256 = MessageDigest.getInstance("SHA-256")
mockMvc
.post("/users/hoge/inbox") {
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
}
.asyncDispatch()
.andExpect {
header("Signature", "a")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}.asyncDispatch().andExpect {
status { isAccepted() }
}
@ -199,4 +221,350 @@ class InboxControllerImplTest {
fun `user-inbox GETリクエストには405を返す`() {
mockMvc.get("/users/hoge/inbox").andExpect { status { isMethodNotAllowed() } }
}
@Test
fun `inbox Dateヘッダーが無いと400`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
}
.asyncDispatch()
.andExpect {
status {
isBadRequest()
}
}
}
@Test
fun `user-inbox Dateヘッダーが無いと400`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
}
.asyncDispatch()
.andExpect {
status {
isBadRequest()
}
}
}
@Test
fun `inbox Dateヘッダーが未来だと401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().plusDays(1).format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `user-inbox Dateヘッダーが未来だと401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().plusDays(1).format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `inbox Dateヘッダーが過去過ぎると401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().minusDays(1).format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `user-inbox Dateヘッダーが過去過ぎると401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().minusDays(1).format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `inbox Hostヘッダーが無いと400`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isBadRequest()
}
}
}
@Test
fun `user-inbox Hostヘッダーが無いと400`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status {
isBadRequest()
}
}
}
@Test
fun `inbox Hostヘッダーが間違ってると401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Host", "example.jp")
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `user-inbox Hostヘッダーが間違ってると401`() {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Host", "example.jp")
}
.asyncDispatch()
.andExpect {
status {
isUnauthorized()
}
}
}
@Test
fun `inbox Digestヘッダーがないと400`() = runTest {
val json = """{"type":"Follow"}"""
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status { isBadRequest() }
}
}
@Test
fun `inbox Digestヘッダーが間違ってると401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(("$json aaaaaaaa").toByteArray()))
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `user-inbox Digestヘッダーがないと400`() = runTest {
val json = """{"type":"Follow"}"""
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
}
.asyncDispatch()
.andExpect {
status { isBadRequest() }
}
}
@Test
fun `user-inbox Digestヘッダーが間違ってると401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(("$json aaaaaaaa").toByteArray()))
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `inbox Signatureヘッダーがないと401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `inbox Signatureヘッダーが空だと401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc
.post("/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `user-inbox Digestヘッダーがないと401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `user-inbox Digestヘッダーが空だと401`() = runTest {
val json = """{"type":"Follow"}"""
val sha256 = MessageDigest.getInstance("SHA-256")
val digest = Base64Util.encode(sha256.digest(json.toByteArray()))
mockMvc
.post("/users/hoge/inbox") {
content = json
contentType = MediaType.APPLICATION_JSON
header("Signature", "")
header("Host", "example.com")
header("Date", ZonedDateTime.now().format(dateTimeFormatter))
header("Digest", "SHA-256=$digest")
}
.asyncDispatch()
.andExpect {
status { isUnauthorized() }
}
}
}

View File

@ -0,0 +1,110 @@
package dev.usbharu.hideout.core.infrastructure.springframework.httpsignature
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.util.Base64Util
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.net.URI
import java.security.MessageDigest
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class HttpSignatureHeaderCheckerTest {
val format = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
@Test
fun `checkDate 未来はダメ`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("http://example.com").toURL()))
val s = ZonedDateTime.now().plusDays(1).format(format)
assertThrows<IllegalArgumentException> {
httpSignatureHeaderChecker.checkDate(s)
}
}
@Test
fun `checkDate 過去はOK`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("http://example.com").toURL()))
val s = ZonedDateTime.now().minusHours(1).format(format)
assertDoesNotThrow {
httpSignatureHeaderChecker.checkDate(s)
}
}
@Test
fun `checkDate 86400秒以上昔はダメ`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("http://example.com").toURL()))
val s = ZonedDateTime.now().minusSeconds(86401).format(format)
assertThrows<IllegalArgumentException> {
httpSignatureHeaderChecker.checkDate(s)
}
}
@Test
fun `checkHost 大文字小文字の違いはセーフ`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("https://example.com").toURL()))
assertDoesNotThrow {
httpSignatureHeaderChecker.checkHost("example.com")
httpSignatureHeaderChecker.checkHost("EXAMPLE.COM")
}
}
@Test
fun `checkHost サブドメインはダメ`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("https://example.com").toURL()))
assertThrows<IllegalArgumentException> {
httpSignatureHeaderChecker.checkHost("follower.example.com")
}
}
@Test
fun `checkDigest リクエストボディが同じなら何もしない`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("https://example.com").toURL()))
val sha256 = MessageDigest.getInstance("SHA-256")
@Language("JSON") val requestBody = """{"@context":"","type":"hoge"}"""
val digest = Base64Util.encode(sha256.digest(requestBody.toByteArray()))
assertDoesNotThrow {
httpSignatureHeaderChecker.checkDigest(requestBody.toByteArray(), "SHA-256=" + digest)
}
}
@Test
fun `checkDigest リクエストボディがちょっとでも違うとダメ`() {
val httpSignatureHeaderChecker =
HttpSignatureHeaderChecker(ApplicationConfig(URI.create("https://example.com").toURL()))
val sha256 = MessageDigest.getInstance("SHA-256")
@Language("JSON") val requestBody = """{"type":"hoge","@context":""}"""
@Language("JSON") val requestBody2 = """{"@context":"","type":"hoge"}"""
val digest = Base64Util.encode(sha256.digest(requestBody.toByteArray()))
assertThrows<IllegalArgumentException> {
httpSignatureHeaderChecker.checkDigest(requestBody2.toByteArray(), digest)
}
}
}