redirect_security_test.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. package lib
  2. import (
  3. "log/slog"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "strings"
  8. "testing"
  9. "github.com/TecharoHQ/anubis/lib/policy"
  10. )
  11. func TestRedirectSecurity(t *testing.T) {
  12. tests := []struct {
  13. reqHost string
  14. testType string // "constructRedirectURL", "serveHTTPNext", "renderIndex"
  15. // For constructRedirectURL tests
  16. xForwardedProto string
  17. xForwardedHost string
  18. xForwardedUri string
  19. // For serveHTTPNext tests
  20. redirParam string
  21. name string
  22. errorContains string
  23. expectedStatus int
  24. // For renderIndex tests
  25. returnHTTPStatusOnly bool
  26. shouldError bool
  27. shouldNotRedirect bool
  28. shouldBlock bool
  29. }{
  30. // constructRedirectURL tests - X-Forwarded-Proto validation
  31. {
  32. name: "constructRedirectURL: javascript protocol should be rejected",
  33. testType: "constructRedirectURL",
  34. xForwardedProto: "javascript",
  35. xForwardedHost: "example.com",
  36. xForwardedUri: "alert(1)",
  37. shouldError: true,
  38. errorContains: "invalid",
  39. },
  40. {
  41. name: "constructRedirectURL: data protocol should be rejected",
  42. testType: "constructRedirectURL",
  43. xForwardedProto: "data",
  44. xForwardedHost: "text/html",
  45. xForwardedUri: ",<script>alert(1)</script>",
  46. shouldError: true,
  47. errorContains: "invalid",
  48. },
  49. {
  50. name: "constructRedirectURL: file protocol should be rejected",
  51. testType: "constructRedirectURL",
  52. xForwardedProto: "file",
  53. xForwardedHost: "",
  54. xForwardedUri: "/etc/passwd",
  55. shouldError: true,
  56. errorContains: "invalid",
  57. },
  58. {
  59. name: "constructRedirectURL: ftp protocol should be rejected",
  60. testType: "constructRedirectURL",
  61. xForwardedProto: "ftp",
  62. xForwardedHost: "example.com",
  63. xForwardedUri: "/file.txt",
  64. shouldError: true,
  65. errorContains: "invalid",
  66. },
  67. {
  68. name: "constructRedirectURL: https protocol should be allowed",
  69. testType: "constructRedirectURL",
  70. xForwardedProto: "https",
  71. xForwardedHost: "example.com",
  72. xForwardedUri: "/foo",
  73. shouldError: false,
  74. },
  75. {
  76. name: "constructRedirectURL: http protocol should be allowed",
  77. testType: "constructRedirectURL",
  78. xForwardedProto: "http",
  79. xForwardedHost: "example.com",
  80. xForwardedUri: "/bar",
  81. shouldError: false,
  82. },
  83. // serveHTTPNext tests - redir parameter validation
  84. {
  85. name: "serveHTTPNext: javascript: URL should be rejected",
  86. testType: "serveHTTPNext",
  87. redirParam: "javascript:alert(1)",
  88. reqHost: "example.com",
  89. expectedStatus: http.StatusBadRequest,
  90. shouldNotRedirect: true,
  91. },
  92. {
  93. name: "serveHTTPNext: data: URL should be rejected",
  94. testType: "serveHTTPNext",
  95. redirParam: "data:text/html,<script>alert(1)</script>",
  96. reqHost: "example.com",
  97. expectedStatus: http.StatusBadRequest,
  98. shouldNotRedirect: true,
  99. },
  100. {
  101. name: "serveHTTPNext: file: URL should be rejected",
  102. testType: "serveHTTPNext",
  103. redirParam: "file:///etc/passwd",
  104. reqHost: "example.com",
  105. expectedStatus: http.StatusBadRequest,
  106. shouldNotRedirect: true,
  107. },
  108. {
  109. name: "serveHTTPNext: vbscript: URL should be rejected",
  110. testType: "serveHTTPNext",
  111. redirParam: "vbscript:msgbox(1)",
  112. reqHost: "example.com",
  113. expectedStatus: http.StatusBadRequest,
  114. shouldNotRedirect: true,
  115. },
  116. {
  117. name: "serveHTTPNext: valid https URL should work",
  118. testType: "serveHTTPNext",
  119. redirParam: "https://example.com/foo",
  120. reqHost: "example.com",
  121. expectedStatus: http.StatusFound,
  122. },
  123. {
  124. name: "serveHTTPNext: valid relative URL should work",
  125. testType: "serveHTTPNext",
  126. redirParam: "/foo/bar",
  127. reqHost: "example.com",
  128. expectedStatus: http.StatusFound,
  129. },
  130. {
  131. name: "serveHTTPNext: external domain should be blocked",
  132. testType: "serveHTTPNext",
  133. redirParam: "https://evil.com/phishing",
  134. reqHost: "example.com",
  135. expectedStatus: http.StatusBadRequest,
  136. shouldBlock: true,
  137. },
  138. {
  139. name: "serveHTTPNext: relative path should work",
  140. testType: "serveHTTPNext",
  141. redirParam: "/safe/path",
  142. reqHost: "example.com",
  143. expectedStatus: http.StatusFound,
  144. },
  145. {
  146. name: "serveHTTPNext: empty redir should show success page",
  147. testType: "serveHTTPNext",
  148. redirParam: "",
  149. reqHost: "example.com",
  150. expectedStatus: http.StatusOK,
  151. },
  152. // renderIndex tests - full subrequest auth flow
  153. {
  154. name: "renderIndex: javascript protocol in X-Forwarded-Proto",
  155. testType: "renderIndex",
  156. xForwardedProto: "javascript",
  157. xForwardedHost: "example.com",
  158. xForwardedUri: "alert(1)",
  159. returnHTTPStatusOnly: true,
  160. expectedStatus: http.StatusBadRequest,
  161. },
  162. {
  163. name: "renderIndex: data protocol in X-Forwarded-Proto",
  164. testType: "renderIndex",
  165. xForwardedProto: "data",
  166. xForwardedHost: "example.com",
  167. xForwardedUri: "text/html,<script>alert(1)</script>",
  168. returnHTTPStatusOnly: true,
  169. expectedStatus: http.StatusBadRequest,
  170. },
  171. {
  172. name: "renderIndex: valid https redirect",
  173. testType: "renderIndex",
  174. xForwardedProto: "https",
  175. xForwardedHost: "example.com",
  176. xForwardedUri: "/protected/page",
  177. returnHTTPStatusOnly: true,
  178. expectedStatus: http.StatusTemporaryRedirect,
  179. },
  180. }
  181. s := &Server{
  182. opts: Options{
  183. PublicUrl: "https://anubis.example.com",
  184. RedirectDomains: []string{},
  185. },
  186. logger: slog.Default(),
  187. policy: &policy.ParsedConfig{},
  188. }
  189. for _, tt := range tests {
  190. t.Run(tt.name, func(t *testing.T) {
  191. switch tt.testType {
  192. case "constructRedirectURL":
  193. req := httptest.NewRequest("GET", "/", nil)
  194. req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto)
  195. req.Header.Set("X-Forwarded-Host", tt.xForwardedHost)
  196. req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
  197. redirectURL, err := s.constructRedirectURL(req)
  198. if tt.shouldError {
  199. if err == nil {
  200. t.Errorf("expected error containing %q, got nil", tt.errorContains)
  201. t.Logf("got redirect URL: %s", redirectURL)
  202. } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
  203. t.Logf("expected error containing %q, got: %v", tt.errorContains, err)
  204. }
  205. } else {
  206. if err != nil {
  207. t.Errorf("expected no error, got: %v", err)
  208. }
  209. // Verify the redirect URL is safe
  210. if redirectURL != "" {
  211. parsed, err := url.Parse(redirectURL)
  212. if err != nil {
  213. t.Errorf("failed to parse redirect URL: %v", err)
  214. }
  215. redirParam := parsed.Query().Get("redir")
  216. if redirParam != "" {
  217. redirParsed, err := url.Parse(redirParam)
  218. if err != nil {
  219. t.Errorf("failed to parse redir parameter: %v", err)
  220. }
  221. if redirParsed.Scheme != "http" && redirParsed.Scheme != "https" {
  222. t.Errorf("redir parameter has unsafe scheme: %s", redirParsed.Scheme)
  223. }
  224. }
  225. }
  226. }
  227. case "serveHTTPNext":
  228. req := httptest.NewRequest("GET", "/.within.website/?redir="+url.QueryEscape(tt.redirParam), nil)
  229. req.Host = tt.reqHost
  230. req.URL.Host = tt.reqHost
  231. rr := httptest.NewRecorder()
  232. s.ServeHTTPNext(rr, req)
  233. if rr.Code != tt.expectedStatus {
  234. t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
  235. t.Logf("body: %s", rr.Body.String())
  236. }
  237. if tt.shouldNotRedirect {
  238. location := rr.Header().Get("Location")
  239. if location != "" {
  240. t.Errorf("expected no redirect, but got Location header: %s", location)
  241. }
  242. }
  243. if tt.shouldBlock {
  244. location := rr.Header().Get("Location")
  245. if location != "" && strings.Contains(location, "evil.com") {
  246. t.Errorf("redirect to evil.com was not blocked: %s", location)
  247. }
  248. }
  249. case "renderIndex":
  250. req := httptest.NewRequest("GET", "/", nil)
  251. req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto)
  252. req.Header.Set("X-Forwarded-Host", tt.xForwardedHost)
  253. req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
  254. rr := httptest.NewRecorder()
  255. s.RenderIndex(rr, req, policy.CheckResult{}, nil, tt.returnHTTPStatusOnly)
  256. if rr.Code != tt.expectedStatus {
  257. t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
  258. }
  259. if tt.expectedStatus == http.StatusTemporaryRedirect {
  260. location := rr.Header().Get("Location")
  261. if location == "" {
  262. t.Error("expected Location header, got none")
  263. } else {
  264. // Verify the location doesn't contain javascript:
  265. if strings.Contains(location, "javascript") {
  266. t.Errorf("Location header contains 'javascript': %s", location)
  267. }
  268. }
  269. }
  270. }
  271. })
  272. }
  273. }