Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interstitial Pages Implementation #705

Merged
merged 17 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## v0.4.36

FEATURE: New interstitial pages that can be enabled per-frontend, and disabled per-account (https://github.com/openziti/zrok/issues/704)

CHANGE: Enable `"declaration": true` in `tsconfig.json` for Node SDK.

## v0.4.35
Expand Down
12 changes: 11 additions & 1 deletion controller/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,17 @@ func (h *shareHandler) Handle(params share.ShareParams, principal *rest_model_zr
logrus.Infof("added frontend selection '%v' with ziti identity '%v' for share '%v'", frontendSelection, sfe.ZId, shrToken)
}
}
shrZId, frontendEndpoints, err = newPublicResourceAllocator().allocate(envZId, shrToken, frontendZIds, frontendTemplates, params, edge)
var skipInterstitial bool
if backendMode != sdk.DriveBackendMode {
skipInterstitial, err = str.IsAccountGrantedSkipInterstitial(int(principal.ID), trx)
if err != nil {
logrus.Errorf("error checking skip interstitial for account '%v': %v", principal.Email, err)
return share.NewShareInternalServerError()
}
} else {
skipInterstitial = true
}
shrZId, frontendEndpoints, err = newPublicResourceAllocator().allocate(envZId, shrToken, frontendZIds, frontendTemplates, params, !skipInterstitial, edge)
if err != nil {
logrus.Error(err)
return share.NewShareInternalServerError()
Expand Down
3 changes: 2 additions & 1 deletion controller/sharePublic.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func newPublicResourceAllocator() *publicResourceAllocator {
return &publicResourceAllocator{}
}

func (a *publicResourceAllocator) allocate(envZId, shrToken string, frontendZIds, frontendTemplates []string, params share.ShareParams, edge *rest_management_api_client.ZitiEdgeManagement) (shrZId string, frontendEndpoints []string, err error) {
func (a *publicResourceAllocator) allocate(envZId, shrToken string, frontendZIds, frontendTemplates []string, params share.ShareParams, interstitial bool, edge *rest_management_api_client.ZitiEdgeManagement) (shrZId string, frontendEndpoints []string, err error) {
var authUsers []*sdk.AuthUserConfig
for _, authUser := range params.Body.AuthUsers {
authUsers = append(authUsers, &sdk.AuthUserConfig{Username: authUser.Username, Password: authUser.Password})
Expand All @@ -23,6 +23,7 @@ func (a *publicResourceAllocator) allocate(envZId, shrToken string, frontendZIds
return "", nil, err
}
options := &zrokEdgeSdk.FrontendOptions{
Interstitial: interstitial,
AuthScheme: authScheme,
BasicAuthUsers: authUsers,
Oauth: &sdk.OauthConfig{
Expand Down
18 changes: 18 additions & 0 deletions controller/store/skipInterstitialGrant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package store

import (
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)

func (str *Store) IsAccountGrantedSkipInterstitial(acctId int, trx *sqlx.Tx) (bool, error) {
stmt, err := trx.Prepare("select count(0) from skip_interstitial_grants where account_id = $1")
if err != nil {
return false, errors.Wrap(err, "error preparing skip_interstitial_grants select statement")
}
var count int
if err := stmt.QueryRow(acctId).Scan(&count); err != nil {
return false, errors.Wrap(err, "error querying skip_interstitial_grants count")
}
return count > 0, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- +migrate Up

create table skip_interstitial_grants (
id serial primary key,

account_id integer references accounts (id) not null,

created_at timestamptz not null default(current_timestamp),
updated_at timestamptz not null default(current_timestamp),
deleted boolean not null default(false)
);

create index skip_interstitial_grants_id_idx on skip_interstitial_grants (account_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- +migrate Up

create table skip_interstitial_grants (
id integer primary key,

account_id integer references accounts (id) not null,

created_at datetime not null default(strftime('%Y-%m-%d %H:%M:%f', 'now')),
updated_at datetime not null default(strftime('%Y-%m-%d %H:%M:%f', 'now')),
deleted boolean not null default(false)
);

create index skip_interstitial_grants_id_idx on skip_interstitial_grants (account_id);
4 changes: 3 additions & 1 deletion controller/zrokEdgeSdk/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import (
)

type FrontendOptions struct {
Interstitial bool
AuthScheme sdk.AuthScheme
BasicAuthUsers []*sdk.AuthUserConfig
Oauth *sdk.OauthConfig
}

func CreateConfig(cfgTypeZId, envZId, shrToken string, options *FrontendOptions, edge *rest_management_api_client.ZitiEdgeManagement) (cfgZId string, err error) {
cfg := &sdk.FrontendConfig{
AuthScheme: options.AuthScheme,
Interstitial: options.Interstitial,
AuthScheme: options.AuthScheme,
}
if cfg.AuthScheme == sdk.Basic {
cfg.BasicAuth = &sdk.BasicAuthConfig{}
Expand Down
57 changes: 57 additions & 0 deletions docs/guides/self-hosting/interstitial-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: Interstitial Pages
sidebar_label: Interstitial Pages
sidebar_position: 18
---

On large zrok installations that support open registration and shared public frontends, abuse can become an issue. In order to mitigate phishing and other similar forms of abuse, zrok offers an interstitial page that announces to the visiting user that the share is hosted through zrok, and probably isn't their financial institution.

Interstitial pages can be enabled on a per-frontend basis. This allows the interstitial to be enabled on open public frontends but not closed public frontends (closed public frontends require a grant to use).

The interstitial page requirement can also be overridden on a per-account basis, allowing shares created by specific accounts to bypass the interstitial requirement on frontends that enable it. This facilitates building infrastructure that grants trusted users additional privileges.

By default, if you do not specifically enable interstitial pages on a public frontend, then your self-hosted service instance will not offer them.

Let's take a look at how the interstitial pages mechanism works. The following diagram shows the share configuration rendezvous made between the zrok controller and a zrok frontend:

![zrok_interstitial_rendezvous](../../images/zrok_interstitial_rendezvous.png)

Every zrok share has a _config_ recorded in the underlying OpenZiti network. The config is of type `zrok.proxy.v1`. The frontend uses the information in this config to understand the disposition of the share. The config can contain an `interstitial: true` setting. If the config has this setting, and the frontend is configured to enable interstitial pages, then end users accessing the share will receive the interstitial page on first visit.

By default the zrok controller will record `interstitial: true` in the share config _unless_ a row is present in the `skip_interstitial_grants` table in the underlying database for the account creating the share. The `skip_interstitial_grants` table is a basic SQL structure that allows inserting a row per account.

```
create table skip_interstitial_grants (
id serial primary key,

account_id integer references accounts (id) not null,

created_at timestamptz not null default(current_timestamp),
updated_at timestamptz not null default(current_timestamp),
deleted boolean not null default(false)
);
```

If an account has a row present in this table when creating a share, then the controller will write `interstitial: false` into the config for the share, which will bypass the interstitial regardless of frontend configuration. The `skip_interstitial_grants` controls what the zrok controller will store in the share config when creating the share.

The frontend configuration controls what the frontend will do with the share config it finds in OpenZiti. The new stanza looks like this:

```
# Setting the `interstitial` setting to `true` will allow this frontend
# to offer interstitial pages if they are configured on the share by the
# controller.
#
#interstitial: true
```

Simply setting `interstitial: true` in the frontend config will allow the configured frontend to offer an interstitial page if the share config enables the interstitial page for that share.

## Bypassing the Interstitial

The interstitial page will be presented unless the client shows up with a `zrok_interstitial` cookie. When the user is presented with the interstitial page, there is a button they can click which sets the necessary cookie and allows them to visit the site. The cookie is set to expire in one week.

End users can offer an HTTP header of `skip_zrok_interstitial`, set to any value to bypass the interstitial page. Setting this header means that the user most likely understands what a zrok share is and will hopefully not fall for a phishing attack.

The `skip_zrok_interstitial` header is especially useful for API clients (like `curl`) and other types of non-interactive clients.

The `drive` backend mode does not currently support `GET` requests and cannot be accessed with a conventional web browser, so it bypasses the interstitial page requirement.
Binary file added docs/images/zrok_interstitial_rendezvous.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion endpoints/proxy/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func NewBackend(cfg *BackendConfig) (*Backend, error) {
return nil, err
}

handler := util.NewProxyHandler(proxy)
handler := util.NewRequestsWrapper(proxy)
return &Backend{
cfg: cfg,
listener: listener,
Expand Down
2 changes: 1 addition & 1 deletion endpoints/proxy/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func NewFrontend(cfg *FrontendConfig) (*Frontend, error) {
}
proxy.Transport = zTransport

handler := authHandler(cfg.ShrToken, util.NewProxyHandler(proxy), "zrok", cfg, zCtx)
handler := authHandler(cfg.ShrToken, util.NewRequestsWrapper(proxy), "zrok", cfg, zCtx)
return &Frontend{
cfg: cfg,
zCtx: zCtx,
Expand Down
18 changes: 10 additions & 8 deletions endpoints/publicProxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import (
const V = 3

type Config struct {
V int
Identity string
Address string
HostMatch string
Oauth *OauthConfig
Tls *endpoints.TlsConfig
V int
Identity string
Address string
HostMatch string
Interstitial bool
Oauth *OauthConfig
Tls *endpoints.TlsConfig
}

type OauthConfig struct {
Expand Down Expand Up @@ -45,8 +46,9 @@ type OauthProviderConfig struct {

func DefaultConfig() *Config {
return &Config{
Identity: "public",
Address: "0.0.0.0:8080",
Identity: "public",
Address: "0.0.0.0:8080",
Interstitial: false,
}
}

Expand Down
19 changes: 17 additions & 2 deletions endpoints/publicProxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/openziti/sdk-golang/ziti"
"github.com/openziti/zrok/endpoints"
"github.com/openziti/zrok/endpoints/publicProxy/healthUi"
"github.com/openziti/zrok/endpoints/publicProxy/interstitialUi"
"github.com/openziti/zrok/endpoints/publicProxy/notFoundUi"
"github.com/openziti/zrok/endpoints/publicProxy/unauthorizedUi"
"github.com/openziti/zrok/environment"
Expand Down Expand Up @@ -73,7 +74,7 @@ func NewHTTP(cfg *Config) (*HttpFrontend, error) {
if err := configureOauthHandlers(context.Background(), cfg, cfg.Tls != nil); err != nil {
return nil, err
}
handler := authHandler(util.NewProxyHandler(proxy), cfg, key, zCtx)
handler := shareHandler(util.NewRequestsWrapper(proxy), cfg, key, zCtx)
return &HttpFrontend{
cfg: cfg,
zCtx: zCtx,
Expand Down Expand Up @@ -151,12 +152,26 @@ func hostTargetReverseProxy(cfg *Config, ctx ziti.Context) *httputil.ReverseProx
return &httputil.ReverseProxy{Director: director}
}

func authHandler(handler http.Handler, pcfg *Config, key []byte, ctx ziti.Context) http.HandlerFunc {
func shareHandler(handler http.Handler, pcfg *Config, key []byte, ctx ziti.Context) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
shrToken := resolveService(pcfg.HostMatch, r.Host)
if shrToken != "" {
if svc, found := endpoints.GetRefreshedService(shrToken, ctx); found {
if cfg, found := svc.Config[sdk.ZrokProxyConfig]; found {
if pcfg.Interstitial {
if v, istlFound := cfg["interstitial"]; istlFound {
if istlEnabled, ok := v.(bool); ok && istlEnabled {
skip := r.Header.Get("skip_zrok_interstitial")
_, zrokOkErr := r.Cookie("zrok_interstitial")
if skip == "" && zrokOkErr != nil {
logrus.Debugf("forcing interstitial for '%v'", r.URL)
interstitialUi.WriteInterstitialAnnounce(w)
return
}
}
}
}

if scheme, found := cfg["auth_scheme"]; found {
switch scheme {
case string(sdk.None):
Expand Down
6 changes: 6 additions & 0 deletions endpoints/publicProxy/interstitialUi/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package interstitialUi

import "embed"

//go:embed index.html
var FS embed.FS
21 changes: 21 additions & 0 deletions endpoints/publicProxy/interstitialUi/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package interstitialUi

import (
"github.com/sirupsen/logrus"
"net/http"
)

func WriteInterstitialAnnounce(w http.ResponseWriter) {
if data, err := FS.ReadFile("index.html"); err == nil {
w.WriteHeader(http.StatusOK)
n, err := w.Write(data)
if n != len(data) {
logrus.Errorf("short write")
return
}
if err != nil {
logrus.Error(err)
return
}
}
}
Loading