diff options
author | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-12-12 12:11:11 +0100 |
---|---|---|
committer | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-12-16 09:40:22 +0100 |
commit | f4389e48ce0a70807362772d66c12ab5cd9e15f8 (patch) | |
tree | 1334516a199dcdf4133758e3664348287e73e88b /hugolib | |
parent | 803f572e66c5e22213ddcc994c41b3e80e9c1f35 (diff) |
Add some basic security policies with sensible defaults
This ommmit contains some security hardening measures for the Hugo build runtime.
There are some rarely used features in Hugo that would be good to have disabled by default. One example would be the "external helpers".
For `asciidoctor` and some others we use Go's `os/exec` package to start a new process.
These are a predefined set of binary names, all loaded from `PATH` and with a predefined set of arguments. Still, if you don't use `asciidoctor` in your project, you might as well have it turned off.
You can configure your own in the new `security` configuration section, but the defaults are configured to create a minimal amount of site breakage. And if that do happen, you will get clear instructions in the loa about what to do.
The default configuration is listed below. Note that almost all of these options are regular expression _whitelists_ (a string or a slice); the value `none` will block all.
```toml
[security]
enableInlineShortcodes = false
[security.exec]
allow = ['^dart-sass-embedded$', '^go$', '^npx$', '^postcss$']
osEnv = ['(?i)^(PATH|PATHEXT|APPDATA|TMP|TEMP|TERM)$']
[security.funcs]
getenv = ['^HUGO_']
[security.http]
methods = ['(?i)GET|POST']
urls = ['.*']
```
Diffstat (limited to 'hugolib')
-rw-r--r-- | hugolib/config.go | 9 | ||||
-rw-r--r-- | hugolib/js_test.go | 10 | ||||
-rw-r--r-- | hugolib/page_test.go | 73 | ||||
-rw-r--r-- | hugolib/resource_chain_babel_test.go | 14 | ||||
-rw-r--r-- | hugolib/resource_chain_test.go | 8 | ||||
-rw-r--r-- | hugolib/securitypolicies_test.go | 202 | ||||
-rw-r--r-- | hugolib/shortcode.go | 4 | ||||
-rw-r--r-- | hugolib/shortcode_test.go | 6 | ||||
-rw-r--r-- | hugolib/site.go | 42 | ||||
-rw-r--r-- | hugolib/testdata/cities.csv | 130 | ||||
-rw-r--r-- | hugolib/testdata/fruits.json | 5 | ||||
-rw-r--r-- | hugolib/testhelpers_test.go | 12 |
12 files changed, 441 insertions, 74 deletions
diff --git a/hugolib/config.go b/hugolib/config.go index 3b5ade598..e79899b94 100644 --- a/hugolib/config.go +++ b/hugolib/config.go @@ -18,6 +18,7 @@ import ( "path/filepath" "strings" + "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/common/maps" @@ -41,6 +42,7 @@ import ( "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config/privacy" + "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/config/services" "github.com/gohugoio/hugo/helpers" "github.com/spf13/afero" @@ -377,6 +379,12 @@ func (l configLoader) collectModules(modConfig modules.Config, v1 config.Provide return nil, nil, err } + secConfig, err := security.DecodeConfig(v1) + if err != nil { + return nil, nil, err + } + ex := hexec.New(secConfig) + v1.Set("filecacheConfigs", filecacheConfigs) var configFilenames []string @@ -405,6 +413,7 @@ func (l configLoader) collectModules(modConfig modules.Config, v1 config.Provide modulesClient := modules.NewClient(modules.ClientConfig{ Fs: l.Fs, Logger: l.Logger, + Exec: ex, HookBeforeFinalize: hook, WorkingDir: workingDir, ThemesDir: themesDir, diff --git a/hugolib/js_test.go b/hugolib/js_test.go index 66c284d8b..69f528758 100644 --- a/hugolib/js_test.go +++ b/hugolib/js_test.go @@ -20,7 +20,6 @@ import ( "runtime" "testing" - "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/htesting" @@ -123,10 +122,9 @@ TS2: {{ template "print" $ts2 }} b.WithSourceFile("assets/js/included.js", includedJS) - cmd, err := hexec.SafeCommand("npm", "install") + cmd := b.NpmInstall() + err = cmd.Run() b.Assert(err, qt.IsNil) - out, err := cmd.CombinedOutput() - b.Assert(err, qt.IsNil, qt.Commentf(string(out))) b.Build(BuildCfg{}) @@ -195,8 +193,8 @@ require github.com/gohugoio/hugoTestProjectJSModImports v0.9.0 // indirect }`) b.Assert(os.Chdir(workDir), qt.IsNil) - cmd, _ := hexec.SafeCommand("npm", "install") - _, err = cmd.CombinedOutput() + cmd := b.NpmInstall() + err = cmd.Run() b.Assert(err, qt.IsNil) b.Build(BuildCfg{}) diff --git a/hugolib/page_test.go b/hugolib/page_test.go index 7a1ff6c4e..50263d483 100644 --- a/hugolib/page_test.go +++ b/hugolib/page_test.go @@ -24,8 +24,6 @@ import ( "github.com/gohugoio/hugo/htesting" - "github.com/gohugoio/hugo/markup/rst" - "github.com/gohugoio/hugo/markup/asciidocext" "github.com/gohugoio/hugo/config" @@ -370,6 +368,7 @@ func normalizeExpected(ext, str string) string { func testAllMarkdownEnginesForPages(t *testing.T, assertFunc func(t *testing.T, ext string, pages page.Pages), settings map[string]interface{}, pageSources ...string) { + engines := []struct { ext string shouldExecute func() bool @@ -377,7 +376,7 @@ func testAllMarkdownEnginesForPages(t *testing.T, {"md", func() bool { return true }}, {"mmark", func() bool { return true }}, {"ad", func() bool { return asciidocext.Supports() }}, - {"rst", func() bool { return rst.Supports() }}, + {"rst", func() bool { return true }}, } for _, e := range engines { @@ -385,47 +384,57 @@ func testAllMarkdownEnginesForPages(t *testing.T, continue } - cfg, fs := newTestCfg(func(cfg config.Provider) error { - for k, v := range settings { - cfg.Set(k, v) + t.Run(e.ext, func(t *testing.T) { + + cfg, fs := newTestCfg(func(cfg config.Provider) error { + for k, v := range settings { + cfg.Set(k, v) + } + return nil + }) + + contentDir := "content" + + if s := cfg.GetString("contentDir"); s != "" { + contentDir = s } - return nil - }) - contentDir := "content" + cfg.Set("security", map[string]interface{}{ + "exec": map[string]interface{}{ + "allow": []string{"^python$", "^rst2html.*", "^asciidoctor$"}, + }, + }) - if s := cfg.GetString("contentDir"); s != "" { - contentDir = s - } + var fileSourcePairs []string - var fileSourcePairs []string + for i, source := range pageSources { + fileSourcePairs = append(fileSourcePairs, fmt.Sprintf("p%d.%s", i, e.ext), source) + } - for i, source := range pageSources { - fileSourcePairs = append(fileSourcePairs, fmt.Sprintf("p%d.%s", i, e.ext), source) - } + for i := 0; i < len(fileSourcePairs); i += 2 { + writeSource(t, fs, filepath.Join(contentDir, fileSourcePairs[i]), fileSourcePairs[i+1]) + } - for i := 0; i < len(fileSourcePairs); i += 2 { - writeSource(t, fs, filepath.Join(contentDir, fileSourcePairs[i]), fileSourcePairs[i+1]) - } + // Add a content page for the home page + homePath := fmt.Sprintf("_index.%s", e.ext) + writeSource(t, fs, filepath.Join(contentDir, homePath), homePage) - // Add a content page for the home page - homePath := fmt.Sprintf("_index.%s", e.ext) - writeSource(t, fs, filepath.Join(contentDir, homePath), homePage) + b := newTestSitesBuilderFromDepsCfg(t, deps.DepsCfg{Fs: fs, Cfg: cfg}).WithNothingAdded() + b.Build(BuildCfg{}) - b := newTestSitesBuilderFromDepsCfg(t, deps.DepsCfg{Fs: fs, Cfg: cfg}).WithNothingAdded() - b.Build(BuildCfg{SkipRender: true}) + s := b.H.Sites[0] - s := b.H.Sites[0] + b.Assert(len(s.RegularPages()), qt.Equals, len(pageSources)) - b.Assert(len(s.RegularPages()), qt.Equals, len(pageSources)) + assertFunc(t, e.ext, s.RegularPages()) - assertFunc(t, e.ext, s.RegularPages()) + home, err := s.Info.Home() + b.Assert(err, qt.IsNil) + b.Assert(home, qt.Not(qt.IsNil)) + b.Assert(home.File().Path(), qt.Equals, homePath) + b.Assert(content(home), qt.Contains, "Home Page Content") - home, err := s.Info.Home() - b.Assert(err, qt.IsNil) - b.Assert(home, qt.Not(qt.IsNil)) - b.Assert(home.File().Path(), qt.Equals, homePath) - b.Assert(content(home), qt.Contains, "Home Page Content") + }) } } diff --git a/hugolib/resource_chain_babel_test.go b/hugolib/resource_chain_babel_test.go index 5cca22ba1..7a97e820a 100644 --- a/hugolib/resource_chain_babel_test.go +++ b/hugolib/resource_chain_babel_test.go @@ -21,8 +21,6 @@ import ( "github.com/gohugoio/hugo/config" - "github.com/gohugoio/hugo/common/hexec" - jww "github.com/spf13/jwalterweatherman" "github.com/gohugoio/hugo/htesting" @@ -51,7 +49,7 @@ func TestResourceChainBabel(t *testing.T) { "devDependencies": { "@babel/cli": "7.8.4", - "@babel/core": "7.9.0", + "@babel/core": "7.9.0", "@babel/preset-env": "7.9.5" } } @@ -94,6 +92,12 @@ class Car2 { v := config.New() v.Set("workingDir", workDir) v.Set("disableKinds", []string{"taxonomy", "term", "page"}) + v.Set("security", map[string]interface{}{ + "exec": map[string]interface{}{ + "allow": []string{"^npx$", "^babel$"}, + }, + }) + b := newTestSitesBuilder(t).WithLogger(logger) // Need to use OS fs for this. @@ -123,8 +127,8 @@ Transpiled3: {{ $transpiled.Permalink }} b.WithSourceFile("babel.config.js", babelConfig) b.Assert(os.Chdir(workDir), qt.IsNil) - cmd, _ := hexec.SafeCommand("npm", "install") - _, err = cmd.CombinedOutput() + cmd := b.NpmInstall() + err = cmd.Run() b.Assert(err, qt.IsNil) b.Build(BuildCfg{}) diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go index 214dda216..0a5b9177c 100644 --- a/hugolib/resource_chain_test.go +++ b/hugolib/resource_chain_test.go @@ -32,8 +32,6 @@ import ( "testing" "time" - "github.com/gohugoio/hugo/common/hexec" - jww "github.com/spf13/jwalterweatherman" "github.com/gohugoio/hugo/common/herrors" @@ -387,8 +385,6 @@ T1: {{ $r.Content }} } func TestResourceChainBasic(t *testing.T) { - t.Parallel() - ts := httptest.NewServer(http.FileServer(http.Dir("testdata/"))) t.Cleanup(func() { ts.Close() @@ -1184,8 +1180,8 @@ class-in-b { b.WithSourceFile("postcss.config.js", postcssConfig) b.Assert(os.Chdir(workDir), qt.IsNil) - cmd, err := hexec.SafeCommand("npm", "install") - _, err = cmd.CombinedOutput() + cmd := b.NpmInstall() + err = cmd.Run() b.Assert(err, qt.IsNil) b.Build(BuildCfg{}) diff --git a/hugolib/securitypolicies_test.go b/hugolib/securitypolicies_test.go new file mode 100644 index 000000000..297f49479 --- /dev/null +++ b/hugolib/securitypolicies_test.go @@ -0,0 +1,202 @@ +// Copyright 2019 The Hugo Authors. All rights reserved. +// +// 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 hugolib + +import ( + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/markup/asciidocext" + "github.com/gohugoio/hugo/markup/pandoc" + "github.com/gohugoio/hugo/markup/rst" + "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" +) + +func TestSecurityPolicies(t *testing.T) { + c := qt.New(t) + + testVariant := func(c *qt.C, withBuilder func(b *sitesBuilder), expectErr string) { + c.Helper() + b := newTestSitesBuilder(c) + withBuilder(b) + + if expectErr != "" { + err := b.BuildE(BuildCfg{}) + b.Assert(err, qt.IsNotNil) + b.Assert(err, qt.ErrorMatches, expectErr) + } else { + b.Build(BuildCfg{}) + } + + } + + httpTestVariant := func(c *qt.C, templ, expectErr string, withBuilder func(b *sitesBuilder)) { + ts := httptest.NewServer(http.FileServer(http.Dir("testdata/"))) + c.Cleanup(func() { + ts.Close() + }) + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", fmt.Sprintf(templ, ts.URL)) + if withBuilder != nil { + withBuilder(b) + } + } + testVariant(c, cb, expectErr) + } + + c.Run("os.GetEnv, denied", func(c *qt.C) { + c.Parallel() + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ os.Getenv "FOOBAR" }}`) + } + testVariant(c, cb, `(?s).*"FOOBAR" is not whitelisted in policy "security\.funcs\.getenv".*`) + }) + + c.Run("os.GetEnv, OK", func(c *qt.C) { + c.Parallel() + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ os.Getenv "HUGO_FOO" }}`) + } + testVariant(c, cb, "") + }) + + c.Run("Asciidoc, denied", func(c *qt.C) { + c.Parallel() + if !asciidocext.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.ad", "foo") + } + + testVariant(c, cb, `(?s).*"asciidoctor" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("RST, denied", func(c *qt.C) { + c.Parallel() + if !rst.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.rst", "foo") + } + + if runtime.GOOS == "windows" { + testVariant(c, cb, `(?s).*python(\.exe)?" is not whitelisted in policy "security\.exec\.allow".*`) + } else { + testVariant(c, cb, `(?s).*"rst2html(\.py)?" is not whitelisted in policy "security\.exec\.allow".*`) + + } + + }) + + c.Run("Pandoc, denied", func(c *qt.C) { + c.Parallel() + if !pandoc.Supports() { + c.Skip() + } + + cb := func(b *sitesBuilder) { + b.WithContent("page.pdc", "foo") + } + + testVariant(c, cb, `"(?s).*pandoc" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("Dart SASS, OK", func(c *qt.C) { + c.Parallel() + if !dartsass.Supports() { + c.Skip() + } + cb := func(b *sitesBuilder) { + b.WithTemplatesAdded("index.html", `{{ $scss := "body { color: #333; }" | resources.FromString "foo.scss" | resources.ToCSS (dict "transpiler" "dartsass") }}`) + } + testVariant(c, cb, "") + }) + + c.Run("Dart SASS, denied", func(c *qt.C) { + c.Parallel() + if !dartsass.Supports() { + c.Skip() + } + cb := func(b *sitesBuilder) { + b.WithConfigFile("toml", ` + [security] + [security.exec] + allow="none" + + `) + b.WithTemplatesAdded("index.html", `{{ $scss := "body { color: #333; }" | resources.FromString "foo.scss" | resources.ToCSS (dict "transpiler" "dartsass") }}`) + } + testVariant(c, cb, `(?s).*"dart-sass-embedded" is not whitelisted in policy "security\.exec\.allow".*`) + }) + + c.Run("resources.Get, OK", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.Get "%[1]s/fruits.json" }}{{ $json.Content }}`, "", nil) + }) + + c.Run("resources.Get, denied method", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.Get "%[1]s/fruits.json" (dict "method" "DELETE" ) }}{{ $json.Content }}`, `(?s).*"DELETE" is not whitelisted in policy "security\.http\.method".*`, nil) + }) + + c.Run("resources.Get, denied URL", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := resources.Get "%[1]s/fruits.json" }}{{ $json.Content }}`, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.http] +urls="none" +`) + }) + }) + + c.Run("getJSON, OK", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := getJSON "%[1]s/fruits.json" }}{{ $json.Content }}`, "", nil) + }) + + c.Run("getJSON, denied URL", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $json := getJSON "%[1]s/fruits.json" }}{{ $json.Content }}`, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.http] +urls="none" +`) + }) + }) + + c.Run("getCSV, denied URL", func(c *qt.C) { + c.Parallel() + httpTestVariant(c, `{{ $d := getCSV ";" "%[1]s/cities.csv" }}{{ $d.Content }}`, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, + func(b *sitesBuilder) { + b.WithConfigFile("toml", ` +[security] +[security.http] +urls="none" +`) + }) + }) + +} diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go index 21d65de32..ec3a4a01b 100644 --- a/hugolib/shortcode.go +++ b/hugolib/shortcode.go @@ -257,7 +257,7 @@ func newShortcodeHandler(p *pageState, s *Site, placeholderFunc func() string) * sh := &shortcodeHandler{ p: p, s: s, - enableInlineShortcodes: s.enableInlineShortcodes, + enableInlineShortcodes: s.ExecHelper.Sec().EnableInlineShortcodes, shortcodes: make([]*shortcode, 0, 4), nameSet: make(map[string]bool), } @@ -287,7 +287,7 @@ func renderShortcode( var hasVariants bool if sc.isInline { - if !p.s.enableInlineShortcodes { + if !p.s.ExecHelper.Sec().EnableInlineShortcodes { return "", false, nil } templName := path.Join("_inline_shortcode", p.File().Path(), sc.name) diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go index 6ef110c9b..6316afc98 100644 --- a/hugolib/shortcode_test.go +++ b/hugolib/shortcode_test.go @@ -619,6 +619,12 @@ title: "Foo" cfg.Set("uglyURLs", false) cfg.Set("verbose", true) + cfg.Set("security", map[string]interface{}{ + "exec": map[string]interface{}{ + "allow": []string{"^python$", "^rst2html.*", "^asciidoctor$"}, + }, + }) + cfg.Set("markup.highlight.noClasses", false) cfg.Set("markup.highlight.codeFences", true) cfg.Set("markup", map[string]interface{}{ diff --git a/hugolib/site.go b/hugolib/site.go index 96cf0b93c..dce4b8d25 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -120,8 +120,6 @@ type Site struct { disabledKinds map[string]bool - enableInlineShortcodes bool - // Output formats defined in site config per Page Kind, or some defaults // if not set. // Output formats defined in Page front matter will override these. @@ -378,25 +376,24 @@ func (s *Site) isEnabled(kind string) bool { // reset returns a new Site prepared for rebuild. func (s *Site) reset() *Site { return &Site{ - Deps: s.Deps, - disabledKinds: s.disabledKinds, - titleFunc: s.titleFunc, - relatedDocsHandler: s.relatedDocsHandler.Clone(), - siteRefLinker: s.siteRefLinker, - outputFormats: s.outputFormats, - rc: s.rc, - outputFormatsConfig: s.outputFormatsConfig, - frontmatterHandler: s.frontmatterHandler, - mediaTypesConfig: s.mediaTypesConfig, - language: s.language, - siteBucket: s.siteBucket, - h: s.h, - publisher: s.publisher, - siteConfigConfig: s.siteConfigConfig, - enableInlineShortcodes: s.enableInlineShortcodes, - init: s.init, - PageCollections: s.PageCollections, - siteCfg: s.siteCfg, + Deps: s.Deps, + disabledKinds: s.disabledKinds, + titleFunc: s.titleFunc, + relatedDocsHandler: s.relatedDocsHandler.Clone(), + siteRefLinker: s.siteRefLinker, + outputFormats: s.outputFormats, + rc: s.rc, + outputFormatsConfig: s.outputFormatsConfig, + frontmatterHandler: s.frontmatterHandler, + mediaTypesConfig: s.mediaTypesConfig, + language: s.language, + siteBucket: s.siteBucket, + h: s.h, + publisher: s.publisher, + siteConfigConfig: s.siteConfigConfig, + init: s.init, + PageCollections: s.PageCollections, + siteCfg: s.siteCfg, } } @@ -564,8 +561,7 @@ But this also means that your site configuration may not do what you expect. If outputFormatsConfig: siteOutputFormatsConfig, mediaTypesConfig: siteMediaTypesConfig, - enableInlineShortcodes: cfg.Language.GetBool("enableInlineShortcodes"), - siteCfg: siteConfig, + siteCfg: siteConfig, titleFunc: titleFunc, diff --git a/hugolib/testdata/cities.csv b/hugolib/testdata/cities.csv new file mode 100644 index 000000000..ee6b058b6 --- /dev/null +++ b/hugolib/testdata/cities.csv @@ -0,0 +1,130 @@ +"LatD", "LatM", "LatS", "NS", "LonD", "LonM", "LonS", "EW", "City", "State" + 41, 5, 59, "N", 80, 39, 0, "W", "Youngstown", OH + 42, 52, 48, "N", 97, 23, 23, "W", "Yankton", SD + 46, 35, 59, "N", 120, 30, 36, "W", "Yakima", WA + 42, 16, 12, "N", 71, 48, 0, "W", "Worcester", MA + 43, 37, 48, "N", 89, 46, 11, "W", "Wisconsin Dells", WI + 36, 5, 59, "N", 80, 15, 0, "W", "Winston-Salem", NC + 49, 52, 48, "N", 97, 9, 0, "W", "Winnipeg", MB + 39, 11, 23, "N", 78, 9, 36, "W", "Winchester", VA + 34, 14, 24, "N", 77, 55, 11, "W", "Wilmington", NC + 39, 45, 0, "N", 75, 33, 0, "W", "Wilmington", DE + 48, 9, 0, "N", 103, 37, 12, "W", "Williston", ND + 41, 15, 0, "N", 77, 0, 0, "W", "Williamsport", PA + 37, 40, 48, "N", 82, 16, 47, "W", "Williamson", WV + 33, 54, 0, "N", 98, 29, 23, "W", "Wichita Falls", TX + 37, 41, 23, "N", 97, 20, 23, "W", "Wichita", KS + 40, 4, 11, "N", 80, 43, 12, "W", "Wheeling", WV + 26, 43, 11, "N", 80, 3, 0, "W", "West Palm Beach", FL + 47, 25, 11, "N", 120, 19, 11, "W", "Wenatchee", WA + 41, 25, 11, "N", 122, 23, 23, "W", "Weed", CA + 31, 13, 11, "N", 82, 20, 59, "W", "Waycross", GA + 44, 57, 35, "N", 89, 38, 23, "W", "Wausau", WI + 42, 21, 36, "N", 87, 49, 48, "W", "Waukegan", IL + 44, 54, 0, "N", 97, 6, 36, "W", "Watertown", SD + 43, 58, 47, "N", 75, 55, 11, "W", "Watertown", NY + 42, 30, 0, "N", 92, 20, 23, "W", "Waterloo", IA + 41, 32, 59, "N", 73, 3, 0, "W", "Waterbury", CT + 38, 53, 23, "N", 77, 1, 47, "W", "Washington", DC + 41, 50, 59, "N", 79, 8, 23, "W", "Warren", PA + 46, 4, 11, "N", 118, 19, 48, "W", "Walla Walla", WA + 31, 32, 59, "N", 97, 8, 23, "W", "Waco", TX + 38, 40, 48, "N", 87, 31, 47, "W", "Vincennes", IN + 28, 48, 35, "N", 97, 0, 36, "W", "Victoria", TX + 32, 20, 59, "N", 90, 52, 47, "W", "Vicksburg", MS + 49, 16, 12, "N", 123, 7, 12, "W", "Vancouver", BC + 46, 55, 11, "N", 98, 0, 36, "W", "Valley City", ND + 30, 49, 47, "N", 83, 16, 47, "W", "Valdosta", GA + 43, 6, 36, "N", 75, 13, 48, "W", "Utica", NY + 39, 54, 0, "N", 79, 43, 48, "W", "Uniontown", PA + 32, 20, 59, "N", 95, 18, 0, "W", "Tyler", TX + 42, 33, 36, "N", 114, 28, 12, "W", "Twin Falls", ID + 33, 12, 35, "N", 87, 34, 11, "W", "Tuscaloosa", AL + 34, 15, 35, "N", 88, 42, 35, "W", "Tupelo", MS + 36, 9, 35, "N", 95, 54, 36, "W", "Tulsa", OK + 32, 13, 12, "N", 110, 58, 12, "W", "Tucson", AZ + 37, 10, 11, "N", 104, 30, 36, "W", "Trinidad", CO + 40, 13, 47, "N", 74, 46, 11, "W", "Trenton", NJ + 44, 45, 35, "N", 85, 37, 47, "W", "Traverse City", MI + 43, 39, 0, "N", 79, 22, 47, "W", "Toronto", ON + 39, 2, 59, "N", 95, 40, 11, "W", "Topeka", KS + 41, 39, 0, "N", 83, 32, 24, "W", "Toledo", OH + 33, 25, 48, "N", 94, 3, 0, "W", "Texarkana", TX + 39, 28, 12, "N", 87, 24, 36, "W", "Terre Haute", IN + 27, 57, 0, "N", 82, 26, 59, "W", "Tampa", FL + 30, 27, 0, "N", 84, 16, 47, "W", "Tallahassee", FL + 47, 14, 24, "N", 122, 25, 48, "W", "Tacoma", WA + 43, 2, 59, "N", 76, 9, 0, "W", "Syracuse", NY + 32, 35, 59, "N", 82, 20, 23, "W", "Swainsboro", GA + 33, 55, 11, "N", 80, 20, 59, "W", "Sumter", SC + 40, 59, 24, "N", 75, 11, 24, "W", "Stroudsburg", PA + 37, 57, 35, "N", 121, 17, 24, "W", "Stockton", CA + 44, 31, 12, "N", 89, 34, 11, "W", "Stevens Point", WI + 40, 21, 36, "N", 80, 37, 12, "W", "Steubenville", OH + 40, 37, 11, "N", 103, 13, 12, "W", "Sterling", CO + 38, 9, 0, "N", 79, 4, 11, "W", "Staunton", VA + 39, 55, 11, "N", 83, 48, 35, "W", "Springfield", OH + 37, 13, 12, "N", 93, 17, 24, "W", "Springfield", MO + 42, 5, 59, "N", 72, 35, 23, "W", "Springfield", MA + 39, 47, 59, "N", 89, 39, 0, "W", "Springfield", IL + 47, 40, 11, "N", 117, 24, 36, "W", "Spokane", WA + 41, 40, 48, "N", 86, 15, 0, "W", "South Bend", IN + 43, 32, 24, "N", 96, 43, 48, "W", "Sioux Falls", SD + 42, 29, 24, "N", 96, 23, 23, "W", "Sioux City", IA + 32, 30, 35, "N", 93, 45, 0, "W", "Shreveport", LA + 33, 38, 23, "N", 96, 36, 36, "W", "Sherman", TX + 44, 47, 59, "N", 106, 57, 35, "W", "Sheridan", WY + 35, 13, 47, "N", 96, 40, 48, "W", "Seminole", OK + 32, 25, 11, "N", 87, 1, 11, "W", "Selma", AL + 38, 42, 35, "N", 93, 13, 48, "W", "Sedalia", MO + 47, 35, 59, "N", 122, 19, 48, "W", "Seattle", WA + 41, 24, 35, "N", 75, 40, 11, "W", "Scranton", PA + 41, 52, 11, "N", 103, 39, 36, "W", "Scottsbluff", NB + 42, 49, 11, "N", 73, 56, 59, "W", "Schenectady", NY + 32, 4, 48, "N", 81, 5, 23, "W", "Savannah", GA + 46, 29, 24, "N", 84, 20, 59, "W", "Sault Sainte Marie", MI + 27, 20, 24, "N", 82, 31, 47, "W", "Sarasota", FL + 38, 26, 23, "N", 122, 43, 12, "W", "Santa Rosa", CA + 35, 40, 48, "N", 105, 56, 59, "W", "Santa Fe", NM + 34, 25, 11, "N", 119, 41, 59, "W", "Santa Barbara", CA + 33, 45, 35, "N", 117, 52, 12, "W", "Santa Ana", CA + 37, 20, 24, "N", 121, 52, 47, "W", "San Jose", CA + 37, 46, 47, "N", 122, 25, 11, "W", "San Francisco", CA + 41, 27, 0, "N", 82, 42, 35, "W", "Sandusky", OH + 32, 42, 35, "N", 117, 9, 0, "W", "San Diego", CA + 34, 6, 36, "N", 117, 18, 35, "W", "San Bernardino", CA + 29, 25, 12, "N", 98, 30, 0, "W", "San Antonio", TX + 31, 27, 35, "N", 100, 26, 24, "W", "San Angelo", TX + 40, 45, 35, "N", 111, 52, 47, "W", "Salt Lake City", UT + 38, 22, 11, "N", 75, 35, 59, "W", "Salisbury", MD + 36, 40, 11, "N", 121, 39, 0, "W", "Salinas", CA + 38, 50, 24, "N", 97, 36, 36, "W", "Salina", KS + 38, 31, 47, "N", 106, 0, 0, "W", "Salida", CO + 44, 56, 23, "N", 123, 1, 47, "W", "Salem", OR + 44, 57, 0, "N", 93, 5, 59, "W", "Saint Paul", MN + 38, 37, 11, "N", 90, 11, 24, "W", "Saint Louis", MO + 39, 46, 12, "N", 94, 50, 23, "W", "Saint Joseph", MO + 42, 5, 59, "N", 86, 28, 48, "W", "Saint Joseph", MI + 44, 25, 11, "N", 72, 1, 11, "W", "Saint Johnsbury", VT + 45, 34, 11, "N", 94, 10, 11, "W", "Saint Cloud", MN + 29, 53, 23, "N", 81, 19, 11, "W", "Saint Augustine", FL + 43, 25, 48, "N", 83, 56, 24, "W", "Saginaw", MI + 38, 35, 24, "N", 121, 29, 23, "W", "Sacramento", CA + 43, 36, 36, "N", 72, 58, 12, "W", "Rutland", VT + 33, 24, 0, "N", 104, 31, 47, "W", "Roswell", NM + 35, 56, 23, "N", 77, 48, 0, "W", "Rocky Mount", NC + 41, 35, 24, "N", 109, 13, 48, "W", "Rock Springs", WY + 42, 16, 12, "N", 89, 5, 59, "W", "Rockford", IL + 43, 9, 35, "N", 77, 36, 36, "W", "Rochester", NY + 44, 1, 12, "N", 92, 27, 35, "W", "Rochester", MN + 37, 16, 12, "N", 79, 56, 24, "W", "Roanoke", VA + 37, 32, 24, "N", 77 |